From 33811dd5581e4261dc13055d40dc068c974bed7e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 20:54:21 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20principles=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=203=20=F0=9F=9F=A1=20(0=20misread);=20row=20secti?= =?UTF-8?q?on=20field,=20one=20heading-level=20predicate,=20items=20171-17?= =?UTF-8?q?4=20filed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adf/block-nodes.ts | 6 ++ src/html/block-elements.ts | 4 +- src/html/parse/html-to-adf.ts | 37 +++++++----- src/markdown/emit/adf-to-markdown.ts | 4 +- .../portable/adf-to-portable-markdown.ts | 4 +- todo.md | 58 ++++++++++++++++++- 6 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/adf/block-nodes.ts b/src/adf/block-nodes.ts index c885ee9..ae06138 100644 --- a/src/adf/block-nodes.ts +++ b/src/adf/block-nodes.ts @@ -83,12 +83,18 @@ export const blockNodes = { taskList: { attributes: localIdAttributes, contentModel: 'block' }, } satisfies Readonly> +export const headingLevels: readonly number[] = [1, 2, 3, 4, 5, 6] + export type BlockType = keyof typeof blockNodes export function blockNodeModel(type: string): BlockNodeModel | undefined { return isBlockType(type) ? blockNodes[type] : undefined } +export function isHeadingLevel(level: unknown): level is number { + return typeof level === 'number' && headingLevels.includes(level) +} + export function isTaskItem(node: AdfNode | undefined): boolean { return node?.type === 'taskItem' || node?.type === 'blockTaskItem' } diff --git a/src/html/block-elements.ts b/src/html/block-elements.ts index 1d166f3..4725817 100644 --- a/src/html/block-elements.ts +++ b/src/html/block-elements.ts @@ -1,15 +1,15 @@ import type { JsonValue } from '../json-value.ts' +import { headingLevels, isHeadingLevel } from '../adf/block-nodes.ts' export type ContainerType = 'blockquote' | 'bulletList' | 'orderedList' // The element of each node whose blocks, or list items, stand a line each inside it. export const containerElements: Readonly> = { blockquote: 'blockquote', bulletList: 'ul', orderedList: 'ol' } -const headingLevels: readonly unknown[] = [1, 2, 3, 4, 5, 6] export const headingElements: readonly string[] = headingLevels.map((level) => `h${level}`) // `undefined` where the level is none of 1 to 6, so the heading rides the carry. export function headingElement(level: JsonValue | undefined): string | undefined { - return headingLevels.includes(level) ? `h${level}` : undefined + return isHeadingLevel(level) ? `h${level}` : undefined } // The level of `

` to `

`, 0 for any other element. diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index 0d31451..df00ad5 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -28,8 +28,13 @@ type BlockReader = (html: string, tag: StartTag, place: Place, open: Open) => Re type BlockStep = { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open } -// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. An open table has kind `rows`, and its closer turns from `headCloser` to `bodyCloser` at ``. -type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'list' | 'row' | 'rows'; node: AdfNode; place: Place | undefined } +// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. An open table has kind `rows` and no `closer`: `section` names the one its rows stand in, and `closerOf` derives its closer from it. +type Open = { content: AdfNode[]; depth: number; node: AdfNode; place: Place | undefined } & ({ closer: string; kind: 'blocks' | 'item' | 'list' | 'row' } | { kind: 'rows'; section: TableSection }) + +// A row's step names the section the table's rows stand in from that row on. +type RowStep = BlockStep & { section: TableSection } + +type TableSection = 'body' | 'head' // No element name opens both a block and an inline node, so the name alone tells an item's line from its next block. const blockReaders: ReadonlyMap = new Map([ @@ -90,13 +95,14 @@ function readDocumentCarry(html: string): Result | unde function readBlocks(html: string): Result { const root: Open = { closer: '', content: [], depth: 0, kind: 'blocks', node: { type: 'doc' }, place: undefined } const opened: Open[] = [] - let open = root + let open: Open = root let index = 0 for (;;) { const place = { index: open.content.length, parent: open.place } if (open.depth > largestNesting) return refused('unsupported-nesting-depth', `blocks nest deeper than ${largestNesting} levels`, html, index, place) const step = readStep(html, index, place, open) if (!step.ok) return step + if (open.kind === 'rows' && 'section' in step.value) open.section = step.value.section index = step.value.end if (step.value.kind === 'opened') { opened.push(open) @@ -104,12 +110,12 @@ function readBlocks(html: string): Result { continue } open.content.push(step.value.node) - while (open !== root && html.startsWith(open.closer, index)) { - index += open.closer.length + while (open !== root && html.startsWith(closerOf(open), index)) { + index += closerOf(open).length const closed: AdfNode = { ...open.node, content: open.content } const closedPlace = open.place open = opened.pop() ?? root - if (open.kind === 'rows' && open.content.length === 0 && isHeaderRow(closed) !== (open.closer === headCloser)) return firstRowRefusal(html, index, closedPlace) + if (open.kind === 'rows' && open.content.length === 0 && isHeaderRow(closed) !== (open.section === 'head')) return firstRowRefusal(html, index, closedPlace) open.content.push(closed) } if (open.kind === 'row') continue @@ -125,7 +131,12 @@ function partedRefusal(html: string, end: number, place: Place | undefined): return refused('unmappable-html', `a line feed parts each block from the next, and ends the HTML: ${foundAt(html, at)}`, html, at, place) } -function readStep(html: string, index: number, place: Place, open: Open): Result { +function closerOf(open: Open): string { + if (open.kind !== 'rows') return open.closer + return open.section === 'head' ? headCloser : bodyCloser +} + +function readStep(html: string, index: number, place: Place, open: Open): Result { if (open.kind === 'list') return readItem(html, index, place, open.depth) if (open.kind === 'rows') return readRow(html, index, place, open) return open.kind === 'row' ? readCell(html, index, place, open.depth) : readBlock(html, index, place, open) @@ -259,15 +270,15 @@ function readTable(html: string, tag: StartTag, place: Place, open: Open): Resul if (html.startsWith('', tag.end)) return success({ end: tag.end + ''.length, kind: 'node', node }) const headed = html.startsWith('\n\n', tag.end) if (!headed && !html.startsWith('\n\n', tag.end)) return refused('unmappable-html', ` holds
straight after it, or a line feed and or on a line of its own: ${foundAt(html, tag.end)}`, html, tag.end, place) - return success({ end: tag.end + '\n\n'.length, kind: 'opened', open: { closer: headed ? headCloser : bodyCloser, content: [], depth: open.depth + 1, kind: 'rows', node, place } }) + return success({ end: tag.end + '\n\n'.length, kind: 'opened', open: { content: [], depth: open.depth + 1, kind: 'rows', node, place, section: headed ? 'head' : 'body' } }) } // `` holds one row, so a second row stands past ``. -function readRow(html: string, index: number, place: Place, table: Open): Result { - const pastHead = table.closer === headCloser && table.content.length > 0 +function readRow(html: string, index: number, place: Place, table: Open & { kind: 'rows' }): Result { + const pastHead = table.section === 'head' && table.content.length > 0 if (pastHead && !html.startsWith(headToBody, index)) return refused('unmappable-html', ` holds one row, then : ${foundAt(html, index)}`, html, index, place) const start = pastHead ? index + headToBody.length : index - if (pastHead) table.closer = bodyCloser + const section = pastHead ? 'body' : table.section const tag = readStartTag(html, start) if (tag?.name !== 'tr') { const stop = misread(html, start, 'a table section holds rows, one to a line', 'no table row of the dialect') @@ -276,8 +287,8 @@ function readRow(html: string, index: number, place: Place, table: Open): Result const data = readDataAttributes(tag.attributes, tag.name, blockNodes.tableRow.attributes, []) if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place) const node = built('tableRow', data.value, []) - if (!html.startsWith('', tag.end)) return success({ end: tag.end, kind: 'opened', open: { closer: '', content: [], depth: table.depth + 1, kind: 'row', node, place } }) - return table.closer === headCloser ? firstRowRefusal(html, tag.end, place) : success({ end: tag.end + ''.length, kind: 'node', node }) + if (!html.startsWith('', tag.end)) return success({ end: tag.end, kind: 'opened', open: { closer: '', content: [], depth: table.depth + 1, kind: 'row', node, place }, section }) + return section === 'head' ? firstRowRefusal(html, tag.end, place) : success({ end: tag.end + ''.length, kind: 'node', node, section }) } function firstRowRefusal(html: string, index: number, place: Place | undefined): Result { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 2e50a5c..466b3d4 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -2,7 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { WrittenFlavour } from '../flavour.ts' import { blockDirectiveForm, documentSpelling } from '../directive/block.ts' -import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts' +import { blockNodeModel, blockNodes, isHeadingLevel } from '../../adf/block-nodes.ts' import { blockText, emptyLine, holdsBlankLine, joinedText, opensWithThematicBreak, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' @@ -250,7 +250,7 @@ function fencedTexts(node: AdfNode): string[] | undefined { function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result | undefined { if (!holdsOnlyAttributes(node, ['level'])) return undefined const level = nodeAttrs(node)['level'] - if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined + if (!isHeadingLevel(level)) return undefined const hashes = '#'.repeat(level) const content = nodeContent(node) if (content.length === 0) return success(commonMarkText(blockText(hashes))) diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 630f42b..5e07c51 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -1,5 +1,5 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' -import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' +import { blockNodeModel, isHeadingLevel, isTaskItem } from '../../adf/block-nodes.ts' import { centeredImage, externalMedia } from '../../adf/plain-image.ts' import { commonMarkSpelling, largestListMarker, spellingMemo, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -212,7 +212,7 @@ function withoutTrailingBlanks(text: string): string { function reduceHeading(node: AdfNode, reduction: Reduction): Result { const level = nodeAttrs(node)['level'] - if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction) + if (!isHeadingLevel(level)) return paragraphOf(nodeContent(node), reduction) const content = reduceInline(nodeContent(node), 'heading', reduction) return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content } diff --git a/todo.md b/todo.md index a8a4e94..e954f03 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 0.1` -`Next ID = 171` +`Next ID = 175` ## Items @@ -27,6 +27,8 @@ | 169 | 0.4.0 | | owner | **Investigate `kovetskiy/mark` in depth and record in a report how its formats differ from ours, where the two projects overlap and how they compare.** | 1 | 1 | 1 | 1 | 3 | 3 by 4 | 6.3 | | 112 | 0.4.0 | | | **Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.** | 2 | 1 | 2 | 3 | 3 | 4 by 4 | 6.2 | | 80 | 0.4.0 | | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 1 | 2 | 3 | 3 | 6 by 4 | 6.0 | +| 171 | 0.4.0 | | principle | **Assert the facts an error message must carry, never its whole sentence, in `markdown-to-adf.test.ts`, `document.test.ts` and `html-to-adf.test.ts`.** | 3 | 1 | 1 | 3 | 6 | code health by 3 | 4.4 | +| 173 | 0.4.0 | | principle | **Keep each description `commonMarkSpellings` gives a refused block beside the emitter's spelling it describes.** | 2 | 1 | 1 | 2 | 4 | code health by 1 | 4.4 | | 81 | 0.4.0 | | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 1 | 1 | 1 | 2 | 3 by 2 | 4.2 | | 147 | 0.4.0 | | | **Ship `adfToPlainText`, writing a document's content with no markup, under a README goal for plain text.** | 2 | 2 | 1 | 3 | 4 | 3 by 4 | 4.2 | | 141 | 0.4.0 | | decision | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 | @@ -34,9 +36,11 @@ | 136 | 0.4.0 | | | **Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 | | 134 | 0.4.0 | | decision | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.8 | | 46 | 0.4.0 | 134, 135, 136 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 2 | 1 | 3 | 4 | 3 by 3 | 3.2 | +| 174 | 0.4.0 | | defect | **Bound the passes `trimmedEdges` takes by a constant, so a line of alternating code and emphasised blanks trims in linear time.** | 3 | 1 | 2 | 4 | 7 | 9 by 3 | 2.3 | | 51 | 0.4.0 | | | **Match a reference label to its definition under Unicode case folding.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 | | 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 | | 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 | +| 172 | 0.4.0 | | principle | **Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.** | 3 | 1 | 1 | 3 | 8 | code health by 2 | 0.8 | | 77 | 0.4.0 | | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 3 | 1 | 3 | 5 | 4 | | 0.0 | | 107 | 0.4.0 | | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 3 | 1 | 3 | 5 | 5 | | 0.0 | | 170 | 0.4.0 | | owner | **Bundle the packed tarball with webpack, esbuild, Rollup and Bun in the gate, failing on any bundler warning, and run each bundle's round-trip in Node.** | 3 | 1 | 1 | 3 | 5 | | 0.0 | @@ -210,6 +214,26 @@ with an empty `path`. An export tool told this about one document in thousands t document's whole tree. Give the node's path, and name its type and the attribute key as `attributeNestingMessage` does. Found by the product-owner review of items 66 and 67, 2026-10-05. +### 171. Assert the facts an error message must carry, never its whole sentence, in `markdown-to-adf.test.ts`, `document.test.ts` and `html-to-adf.test.ts`. + +README → Audience says a message is free text that may change in any release, yet `content()` in +`src/markdown/parse/markdown-to-adf.test.ts` folds a failure into `${code}: ${message}`, about 103 +assertions compare that whole sentence, and `document.test.ts` (7) and `html-to-adf.test.ts` (3) +pin `message:` the same way. A rewording fails tests with no behaviour changed, while the facts +§`message` and `path` requires (the violation, the escape named) are never asserted on their own. +Assert `code`, `path`, `position` and `assert.match(message, …)` on the facts the text carries. +Technical principle: "Pin wording only where the wording is the contract". Found by the principles +audit of the release review of 0.3.0, 2026-11-08. + +### 173. Keep each description `commonMarkSpellings` gives a refused block beside the emitter's spelling it describes. + +`commonMarkSpellings` (`src/markdown/parse/markdown-to-adf.ts`) restates in prose the spellings +`spellReadableBlock` (`src/markdown/emit/adf-to-markdown.ts`) owns, such as "each item behind `- `, +or behind `* ` where …". A changed spelling leaves the refusal naming the old one, and a node +missing from the map is refused naming no spelling. Put each description beside the spelling and +export it through `emit/spelling-questions.ts`, the one import `parse/` may take from `emit/`. +Technical principle: DRY. Found by the principles audit of the release review of 0.3.0, 2026-11-08. + ### 81. Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds. `src/result.test.ts` checks the union against the call sites only, so a code added or removed @@ -260,6 +284,27 @@ or report the unminified gzip). The figure lands in README §The package beside dependencies" claim, and in a size column of the comparison table. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 kB. +### 174. Bound the passes `trimmedEdges` takes by a constant, so a line of alternating code and emphasised blanks trims in linear time. + +`trimmedEdges` (`src/markdown/portable/inline-reduction.ts`) repeats a pass until nothing changes, and +each pass costs O(leaves). Leaves alternating `' '` marked code with `' '` marked em take +floor(n/4) + 2 passes: 26, 251 and 2 501 at n = 100, 1 000 and 10 000, so the trim is quadratic, +against goal 9. The same holds for `'x'` code beside `' '` em, and for `[link, code] 'x'` beside +`[link, em] ' '`. The smallest line is six leaves `' '` marked code, em, code, em, code, em, which +takes 3 passes. The passes in a random search of 20 000 short lines never went above 4. + +The cause: under `spanned` grouping an em or link run crosses the code leaves, which can't hold +it, so one run spans the line and only its two end leaves stand beside a neighbour outside it. Each +pass strips em from those two, and the next pair is exposed. A single pass cannot take the fixpoint +directly: every pass judges all leaves against one snapshot, and `edgeOutcome` (drop at a line edge +while a run is shared, keep when not) isn't monotone. A hard break between `[link, em] ' '` and +`[link, em] ' b'` trims to `b` today, but to a linked space and `b` if the first leaf's drop is seen +first. Three ways forward: make the edge rule monotone, which changes output in edge cases and +needs a decision and a writer panel; keep run extents under truncation in `runsSharedWithNext` +(`emit/inline-runs.ts`), exact but large; or state the cost as an exception under goal 9. Count passes +to measure, never time (§Cost fixes are measured, never timed). Related: item 42 trims blanks inside +one leaf. Found by the principles audit of the release review of 0.3.0, 2026-11-08. + ### 51. Match a reference label to its definition under Unicode case folding. `link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec @@ -286,6 +331,17 @@ panel of the developer personas picks the API among those the goals allow: a par `htmlToAdf` or a separate call; Goal 8's "nothing to configure" and Goal 6.1's naming bear on the choice. Split into items once the API is picked. +### 172. Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop. + +134 loops run several readers or cases inside one test body, for example +`for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf])` at lines 97, +279, 771, 917, 926, 962 and 1118 of `src/markdown/parse/markdown-to-adf.test.ts`; the most are in +`adf-schema.test.ts` (18), `limits.test.ts` (12) and `portable-markdown-to-adf.test.ts` (10). The +first failing reader stops the test and the failure never names the reader. Write +`for (const read of readers) test(`… in ${read.name}`, …)` at the top level. Technical principle: +"No loops or branches in a test body; parametrize the cases". Found by the principles audit of the +release review of 0.3.0, 2026-11-08. + ### 77. Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result. Lands with item 45, in one chunk: item 45's reader returns the copy. The maintainer's call,