3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 12:18:28 +01:00

Answer architecture round 1: name a non-string by typeof alone, name and comment what the seats could not read, file items 81 and 82

This commit is contained in:
2026-10-05 09:28:06 +02:00
committed by Lillem4n
parent 2c4fadbd8b
commit 41ac4510f5
12 changed files with 51 additions and 20 deletions
+4 -2
View File
@@ -65,8 +65,6 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now
converts; markdown the parser refused with one of them now refuses with the code the table gives.
`not-a-string` joins it: a `switch` naming every code needs its case. The markdown readers return
it for a value that is not a string, which threw or read as an empty document before.
| Input | `0.1.0` | `0.2.0` |
| --- | --- | --- |
@@ -83,3 +81,7 @@ it for a value that is not a string, which threw or read as an empty document be
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written with a closer | `unsupported-node-shape` | parses |
`ConvertErrorCode` gains `not-a-string`: a `switch` naming every code needs its case. The markdown
readers return it for a value that is not a string, which threw or read as an empty document
before.
+1 -1
View File
@@ -208,7 +208,7 @@ function notADocument(message: string): ConvertFault {
return { code: 'not-an-adf-document', message }
}
export function describe(value: unknown): string {
function describe(value: unknown): string {
if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value)
if (typeof value === 'function') return 'a function'
if (typeof value === 'object' && value !== null) return Array.isArray(value) ? 'an array' : 'an object'
+5 -5
View File
@@ -96,7 +96,7 @@ function interruptsParagraph(node: AdfNode): boolean {
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const model = blockNodeModel(node.type)
if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (model === undefined) return carriedFence(carriedBlock(node, path, depth))
const readable = readableBlock(node, path, depth, writing)
if (readable !== undefined) return readable
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
@@ -207,7 +207,7 @@ function readableText(text: string | undefined): Result<EmittedBlock> | undefine
return text === undefined ? undefined : success(commonMarkText(text))
}
function commonMarkLine(carried: Result<{ headroom: number; text: string }>): Result<EmittedBlock> {
function carriedFence(carried: Result<{ headroom: number; text: string }>): Result<EmittedBlock> {
if (!carried.ok) return carried
return success({ ...carried.value, spelling: 'commonmark' })
}
@@ -225,7 +225,7 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
const opener = spellBlockDirectiveOpener(node, model, path)
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (opener === undefined) return carriedFence(carriedBlock(node, path, depth))
if (!opener.ok) return opener
return emitDirectiveBody(node, model, opener.value, path, walkBody)
}
@@ -263,10 +263,10 @@ function tryCodeBlock(node: AdfNode): string | undefined {
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const texts = fencedTexts(node)
if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (texts === undefined) return carriedFence(carriedBlock(node, path, depth))
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (opener === undefined) return carriedFence(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const info = slot.kind === 'fence' ? slot.info : ''
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
+2
View File
@@ -11,6 +11,7 @@ import { readEntityReference } from '../commonmark/entity-references.ts'
export type DelimiterRole = 'close' | 'open'
// A segment's kind: `backslash` text, `bracketed` text inside `[…]` (link text, alt, a directive's content), `bracketed-link-target` a link target inside `[…]`, `none` syntax written as is.
export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
export type NodeRange = { first: number; last: number }
@@ -143,6 +144,7 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], cl
if (segmentEnd !== end || claimed.has(start)) formed.add(segmentEnd - start + 1)
else if (escapings[start] !== 'none' && formed.has(end - start + 1)) {
for (let index = start; index <= end; index += 1) escaped.add(index)
// Each escaped backtick then stands alone, a string of length one.
formed.add(1)
}
end = start - 1
+1 -2
View File
@@ -2,13 +2,13 @@ import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts'
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:'
@@ -32,7 +32,6 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
}
// The type a fence's info string names, `undefined` where the info string opens no carry.
export function carryFenceType(info: string): string | undefined {
return info.startsWith(carryFencePrefix) ? info.slice(carryFencePrefix.length) : undefined
}
+1 -1
View File
@@ -648,7 +648,7 @@ function markType(character: string, used: number): string {
return used === 2 ? 'strong' : 'em'
}
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
// Nested spellings of one mark (`*(*a*)*`) name it once: a node carries one mark of a type.
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => {
if (!isNode(node)) return node
+3 -1
View File
@@ -85,7 +85,9 @@ function table(...rows: AdfNode[]): AdfNode {
}
test('refuses a value that is not a string, in both readers, at the start of the input', () => {
const found: [unknown, string][] = [[null, 'null'], [undefined, 'undefined'], [42, '42'], [{}, 'an object'], [['x'], 'an array']]
const revoked = Proxy.revocable({}, {})
revoked.revoke()
const found: [unknown, string][] = [[null, 'null'], [undefined, 'undefined'], [42, 'a number'], [10n, 'a bigint'], [['x'], 'an object'], [revoked.proxy, 'an object']]
for (const read of [markdownToAdf, plainMarkdownToAdf]) {
for (const [value, described] of found) {
assert.deepEqual(Reflect.apply(read, undefined, [value]), {
+10 -2
View File
@@ -8,7 +8,7 @@ import type { LinkDefinitions } from './inline-content.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
import { describe, emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { inlineLeaves } from '../plain/inline-reduction.ts'
import { languageSlot } from '../code-language.ts'
@@ -39,7 +39,7 @@ export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseE
}
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${describe(markdown)}`, []), documentStart)
if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []), documentStart)
const parsed = parseBlocks(markdown)
const [only] = parsed.blocks
if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) {
@@ -54,6 +54,14 @@ function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, P
return success(document)
}
// `typeof` alone, since anything more throws on a revoked Proxy.
function typeName(value: unknown): string {
if (value === null) return 'null'
const type = typeof value
if (type === 'undefined') return type
return type === 'object' ? 'an object' : `a ${type}`
}
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = []
+3 -2
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
@@ -51,7 +51,7 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
// Two documents the editor holds equal write the same plain markdown (docs/decisions.md §Equality is deep).
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) {
// Merging text renumbers siblings, so a refusal's path comes from the caller's document.
@@ -122,6 +122,7 @@ function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'plain', memo: reduction.memo })?.ok === true
if (spelled) continue
sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block])
// Step back to the merged neighbour, which may now take the directive form itself.
index = Math.max(0, index - 1) - 1
}
return success(sequence)
+3 -2
View File
@@ -17,7 +17,7 @@ const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, path, depth)
if (!leaves.ok) return leaves
return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path)
return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path)
}
export function isBlockNodeType(type: string): boolean {
@@ -167,7 +167,7 @@ export function writableHref(href: string): string {
}
// The marks a whole highlight run shares go outside the highlight, so its delimiters open and close inside them.
function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
function highlightsInsideSharedMarks(leaves: readonly AdfNode[]): AdfNode[] {
const spelled: AdfNode[] = []
let run: AdfNode[] = []
let shared: AdfMark[] = []
@@ -237,6 +237,7 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode |
return edges
}
// How many of the leaf's marks the edge whitespace keeps, `undefined` where it goes at a line edge.
function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined {
if (whitespace === '') return marks.length
const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak'
+1 -1
View File
@@ -1,2 +1,2 @@
// A level is one block-list recursion in either direction: a readable list's items sit one below it, its directive spelling's two.
// A level is one block-list recursion in either direction: a readable list's items sit one level below the list, a directive-spelled list's two.
export const largestNesting = 500
+17 -1
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 81`
`Next ID = 83`
| Goal | W |
|---|---|
@@ -35,6 +35,7 @@
| 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 |
| 75 | 0.2.0 | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 2 | 2 | 3 | 7 | 1 | 17.5 |
| 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 4, 5, 7 | 17.1 |
| 81 | 0.2.0 | | **Fail the gate when the README's error tables list other codes than `ConvertErrorCode`.** | 1 | 2 | 5 | 5 | 3 | 16.3 |
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
| 46 | 0.2.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 |
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
@@ -56,6 +57,7 @@
| 33 | 0.3.0 | defect | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
| 76 | 0.3.0 | principle | **State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.** | 2 | 2 | 1 | 3 | 1 | 5.5 |
| 82 | 0.3.0 | principle | **Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.** | 1 | 3 | 1 | 2 | 1 | 4.2 |
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 8 | 0.6 |
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 8 | -2.6 |
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 3 | 18.6 |
@@ -163,6 +165,12 @@ its paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `
alone in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A
writer panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 7.
### 81. Fail the gate when the README's error tables list other codes than `ConvertErrorCode`.
`src/result.test.ts` checks the union against the call sites only, so a code added or removed
leaves the README's tables a false contract under a green gate. Assert that the tables list exactly
the union. Found by the technical-principles audit of item 78, 2026-10-05.
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
@@ -325,6 +333,14 @@ assumes they agree. A helper added to one list lints test code under the shipped
shipped code from coverage, silently. Derive two from the third, or assert in a test that the three
agree. Found by the technical-principles audit, 2026-10-04.
### 82. Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.
Technical principle: tests use random UUID v7 ids. The placeholders sit in
`markdown-to-adf.test.ts`, `adf-to-markdown.test.ts`, `blocks.test.ts` and
`corpus/errors/directive-value-uncanonical.md`, so no lossless directive test reads the digit-led hex
id an editor writes; `plain-markdown-to-adf.test.ts` already uses v7s. Found by the
technical-principles audit of item 78, 2026-10-05.
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
Technical principle "Compose, do not entangle": Deno, Bun and the tests read the install leg's