Answer the architecture pass: one fault vocabulary, and the guards pinned where they compose
CI / gate (push) Successful in 5s

This commit is contained in:
2026-09-01 10:01:35 +02:00
parent f328ed4cab
commit f231f652ca
11 changed files with 59 additions and 38 deletions
+6 -2
View File
@@ -89,7 +89,10 @@ direction hits it. A claim code names the spelling claimed, never the node that
a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node. A directive whose name reads back to no node is spelling writes rides the carry with its node. A directive whose name reads back to no node is
`unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that `unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that
apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A
refusal found before its position is known — the block walk's, a directive reader's — is a
`ConvertFault`, the code and message without the path; the node walk attaches the path as it
descends, so a document reports its first error in document order.
## 9. Release automation ## 9. Release automation
@@ -122,7 +125,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
- Failures are values: everything returns - Failures are values: everything returns
`Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
the spot. the spot. A reader with no path to name returns `Read<T>` instead, the same two arms over a
`ConvertFault`, and `faulted` attaches the path where the walk knows it.
- Only the hard break's inline segment holds a raw newline — every other spelling escapes one or - Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
refuses it — which is how the whitespace carry finds a line edge. refuses it — which is how the whitespace carry finds a line edge.
- Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text - Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
@@ -0,0 +1 @@
unsupported-nesting-depth
+1
View File
@@ -0,0 +1 @@
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > :a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[:a[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
+6 -6
View File
@@ -9,13 +9,13 @@ function attributes(...pairs: [string, string][]): ReadonlyMap<string, string> {
return new Map(pairs) return new Map(pairs)
} }
function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): DirectiveLine { function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): { value: DirectiveLine } {
return { argument, attributes: attributes(...pairs), colons, kind: 'header', name } return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } }
} }
function fault(line: string): string { function fault(line: string): string {
const read = readDirectiveLine(line) const read = readDirectiveLine(line)
return read?.kind === 'fault' ? read.fault.message : `read ${JSON.stringify(read)}` return read?.fault === undefined ? `read ${JSON.stringify(read)}` : read.fault.message
} }
function inline(text: string): unknown { function inline(text: string): unknown {
@@ -37,9 +37,9 @@ test('claims a colon-run line only where a name or nothing follows the colons',
}) })
test('reads a bare colon run as the fence that closes a container', () => { test('reads a bare colon run as the fence that closes a container', () => {
assert.deepEqual(readDirectiveLine(':::'), { colons: 3, kind: 'closing' }) assert.deepEqual(readDirectiveLine(':::'), { value: { colons: 3, kind: 'closing' } })
assert.deepEqual(readDirectiveLine('::'), { colons: 2, kind: 'closing' }) assert.deepEqual(readDirectiveLine('::'), { value: { colons: 2, kind: 'closing' } })
assert.deepEqual(readDirectiveLine(':::::: \t'), { colons: 6, kind: 'closing' }) assert.deepEqual(readDirectiveLine(':::::: \t'), { value: { colons: 6, kind: 'closing' } })
}) })
test('reads the leaf and container forms, their argument and their attributes', () => { test('reads the leaf and container forms, their argument and their attributes', () => {
+8 -9
View File
@@ -12,11 +12,10 @@ export type DirectiveAttributes = ReadonlyMap<string, string>
export type DirectiveLine = export type DirectiveLine =
| { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } | { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string }
| { colons: number; kind: 'closing' } | { colons: number; kind: 'closing' }
| { fault: ConvertFault; kind: 'fault' }
export type InlineDirective = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string }
type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T }
type Attributes = { attributes: DirectiveAttributes; length: number } type Attributes = { attributes: DirectiveAttributes; length: number }
@@ -50,17 +49,17 @@ export function opensInlineDirective(text: string, index: number): boolean {
return inlineDirectiveName(text, index) !== undefined return inlineDirectiveName(text, index) !== undefined
} }
export function readDirectiveLine(line: string): DirectiveLine | undefined { export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined {
if (!claimsDirectiveLine(line)) return undefined if (!claimsDirectiveLine(line)) return undefined
const colons = runLength(line, 0) const colons = runLength(line, 0)
const rest = line.slice(colons) const rest = line.slice(colons)
if (lineEnd.test(rest)) return { colons, kind: 'closing' } if (lineEnd.test(rest)) return { value: { colons, kind: 'closing' } }
const header = readDirectiveHeader(rest) const header = readDirectiveHeader(rest)
if (header.fault !== undefined) return { fault: header.fault, kind: 'fault' } if (header.fault !== undefined) return { fault: header.fault }
return { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } return { value: { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } }
} }
export function readInlineDirective(text: string, index: number): Read<InlineDirective> | undefined { export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> | undefined {
return readNestedDirective(text, index, 1) return readNestedDirective(text, index, 1)
} }
@@ -130,7 +129,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined;
return { value: { argument, attributes, name } } return { value: { argument, attributes, name } }
} }
function readNestedDirective(text: string, index: number, depth: number): Read<InlineDirective> | undefined { function readNestedDirective(text: string, index: number, depth: number): Read<DirectiveSpan> | undefined {
const name = inlineDirectiveName(text, index) const name = inlineDirectiveName(text, index)
if (name === undefined) return undefined if (name === undefined) return undefined
if (depth > largestNesting) { if (depth > largestNesting) {
+10
View File
@@ -87,6 +87,16 @@ test('holds a directive container open until the fence that closes it', () => {
assert.deepEqual(faults(':::panel info\n> Part.\n> :::\n'), []) assert.deepEqual(faults(':::panel info\n> Part.\n> :::\n'), [])
assert.deepEqual(faults('::::panel info\n- :::expand\n Part.\n :::\n::::\n'), []) assert.deepEqual(faults('::::panel info\n- :::expand\n Part.\n :::\n::::\n'), [])
assert.deepEqual(faults(':::panel info\n```\n:::\n```\n:::\n'), []) assert.deepEqual(faults(':::panel info\n```\n:::\n```\n:::\n'), [])
assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [
{
argument: 'info',
attributes: new Map([['panelColor', '#ff0000']]),
blocks: [{ kind: 'paragraph', text: 'Part.' }],
kind: 'directive',
name: 'panel',
},
])
assert.deepEqual(parseBlocks('::rule\n').blocks, [{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule' }])
}) })
test('names the directive fence a container does not sit longer than', () => { test('names the directive fence a container does not sit longer than', () => {
+11 -9
View File
@@ -181,10 +181,15 @@ function openContainer(walk: Walk, start: ContainerStart): void {
function closeContainers(walk: Walk, depth: number): void { function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk) closeLeaf(walk)
for (const container of walk.stack.splice(depth)) { for (const container of walk.stack.slice(depth)) {
if (container.kind !== 'directive') continue if (container.kind !== 'directive') continue
container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' } container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' }
} }
dropContainers(walk, depth)
}
function dropContainers(walk: Walk, depth: number): void {
walk.stack.length = depth
} }
function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'header' }>): void { function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'header' }>): void {
@@ -200,12 +205,7 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'he
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent }) if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent })
} }
function readDirective(walk: Walk, directive: DirectiveLine): void { function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
closeLeaf(walk)
if (directive.kind === 'fault') {
pushFault(walk, directive.fault)
return
}
const enclosing = innermostDirective(walk) const enclosing = innermostDirective(walk)
if (directive.kind === 'closing') { if (directive.kind === 'closing') {
closeDirective(walk, directive.colons, enclosing) closeDirective(walk, directive.colons, enclosing)
@@ -227,7 +227,7 @@ function closeDirective(walk: Walk, colons: number, enclosing: { container: Open
pushFault(walk, malformedDirective(`a closing fence is shorter than the ${enclosing.container.colons} colons it would close`)) pushFault(walk, malformedDirective(`a closing fence is shorter than the ${enclosing.container.colons} colons it would close`))
return return
} }
walk.stack.length = enclosing.depth dropContainers(walk, enclosing.depth)
} }
function innermostDirective(walk: Walk): { container: OpenDirective; depth: number } | undefined { function innermostDirective(walk: Walk): { container: OpenDirective; depth: number } | undefined {
@@ -292,7 +292,9 @@ function openLeaf(walk: Walk, line: Line): void {
const opener = removeColumns(line, largestOpenerIndentation).text const opener = removeColumns(line, largestOpenerIndentation).text
const directive = readDirectiveLine(opener) const directive = readDirectiveLine(opener)
if (directive !== undefined) { if (directive !== undefined) {
readDirective(walk, directive) closeLeaf(walk)
if (directive.fault === undefined) applyDirectiveLine(walk, directive.value)
else pushFault(walk, directive.fault)
return return
} }
if (claimsPipeLine(opener)) { if (claimsPipeLine(opener)) {
+2 -3
View File
@@ -4,7 +4,7 @@ import type { LinkDefinition } from '../link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readInlineDirective, unknownDirectiveFault } from '../directive-syntax.ts' import { readInlineDirective, unknownDirectiveFault } from '../directive-syntax.ts'
@@ -144,8 +144,7 @@ function readDirective(scan: Scan, index: number): Result<number> {
scan.pending += ':' scan.pending += ':'
return success(index + 1) return success(index + 1)
} }
const fault = directive.fault ?? unknownDirectiveFault(directive.value.name) return faulted(directive.fault ?? unknownDirectiveFault(directive.value.name), scan.path)
return failure(fault.code, fault.message, scan.path)
} }
function flush(scan: Scan, strip: boolean): void { function flush(scan: Scan, strip: boolean): void {
+4 -6
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from './blocks.ts' import type { Block } from './blocks.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
@@ -33,12 +33,10 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
case 'code': case 'code':
return success(codeBlockNode(block.language, block.text)) return success(codeBlockNode(block.language, block.text))
case 'directive': { case 'directive':
const fault = unknownDirectiveFault(block.name) return faulted(unknownDirectiveFault(block.name), path)
return failure(fault.code, fault.message, path)
}
case 'fault': case 'fault':
return failure(block.fault.code, block.fault.message, path) return faulted(block.fault, path)
case 'heading': case 'heading':
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path) return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path)
case 'html': case 'html':
+7 -2
View File
@@ -18,12 +18,13 @@ export type ConvertErrorCode =
export type ConvertErrorPath = readonly (number | string)[] export type ConvertErrorPath = readonly (number | string)[]
export type ConvertFault = { export type ConvertError = {
code: ConvertErrorCode code: ConvertErrorCode
message: string message: string
path: ConvertErrorPath
} }
export type ConvertError = ConvertFault & { path: ConvertErrorPath } export type ConvertFault = Omit<ConvertError, 'path'>
export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T } export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T }
@@ -31,6 +32,10 @@ export function failure<T>(code: ConvertErrorCode, message: string, path: Conver
return { error: { code, message, path }, ok: false } return { error: { code, message, path }, ok: false }
} }
export function faulted<T>(fault: ConvertFault, path: ConvertErrorPath): Result<T> {
return failure(fault.code, fault.message, path)
}
export function success<T>(value: T): Result<T> { export function success<T>(value: T): Result<T> {
return { ok: true, value } return { ok: true, value }
} }
+3 -1
View File
@@ -286,7 +286,9 @@ detail is settled at its own milestone.
the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a
MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes
`directive-syntax.ts` with the readers in it: the whole directive grammar, both `directive-syntax.ts` with the readers in it: the whole directive grammar, both
directions, beside the escaping regexes and the spellings it must not drift from. directions, beside the escaping regexes and the spellings it must not drift from. And the
500-level guards compose here for the first time — a recursive reader stacked on the block
walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third.
- [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first - [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
directory to. A parsed directive becomes its node: the name to the type and an unknown one directory to. A parsed directive becomes its node: the name to the type and an unknown one
to a named error, the arg to the attribute it names, each value to the type its section to a named error, the arg to the attribute it names, each value to the type its section