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
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
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
@@ -122,7 +125,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
- Failures are values: everything returns
`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
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
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
@@ -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)
}
function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): DirectiveLine {
return { argument, attributes: attributes(...pairs), colons, kind: 'header', name }
function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): { value: DirectiveLine } {
return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } }
}
function fault(line: string): string {
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 {
@@ -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', () => {
assert.deepEqual(readDirectiveLine(':::'), { colons: 3, kind: 'closing' })
assert.deepEqual(readDirectiveLine('::'), { colons: 2, kind: 'closing' })
assert.deepEqual(readDirectiveLine(':::::: \t'), { colons: 6, kind: 'closing' })
assert.deepEqual(readDirectiveLine(':::'), { value: { colons: 3, kind: 'closing' } })
assert.deepEqual(readDirectiveLine('::'), { value: { colons: 2, kind: 'closing' } })
assert.deepEqual(readDirectiveLine(':::::: \t'), { value: { colons: 6, kind: 'closing' } })
})
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 =
| { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string }
| { 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 }
@@ -50,17 +49,17 @@ export function opensInlineDirective(text: string, index: number): boolean {
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
const colons = runLength(line, 0)
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)
if (header.fault !== undefined) return { fault: header.fault, kind: 'fault' }
return { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name }
if (header.fault !== undefined) return { fault: header.fault }
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)
}
@@ -130,7 +129,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined;
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)
if (name === undefined) return undefined
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- :::expand\n Part.\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', () => {
+11 -9
View File
@@ -181,10 +181,15 @@ function openContainer(walk: Walk, start: ContainerStart): void {
function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk)
for (const container of walk.stack.splice(depth)) {
for (const container of walk.stack.slice(depth)) {
if (container.kind !== 'directive') continue
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 {
@@ -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 })
}
function readDirective(walk: Walk, directive: DirectiveLine): void {
closeLeaf(walk)
if (directive.kind === 'fault') {
pushFault(walk, directive.fault)
return
}
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
const enclosing = innermostDirective(walk)
if (directive.kind === 'closing') {
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`))
return
}
walk.stack.length = enclosing.depth
dropContainers(walk, enclosing.depth)
}
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 directive = readDirectiveLine(opener)
if (directive !== undefined) {
readDirective(walk, directive)
closeLeaf(walk)
if (directive.fault === undefined) applyDirectiveLine(walk, directive.value)
else pushFault(walk, directive.fault)
return
}
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 { backtickRun, closingBacktickRun } from '../backtick-runs.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 { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readInlineDirective, unknownDirectiveFault } from '../directive-syntax.ts'
@@ -144,8 +144,7 @@ function readDirective(scan: Scan, index: number): Result<number> {
scan.pending += ':'
return success(index + 1)
}
const fault = directive.fault ?? unknownDirectiveFault(directive.value.name)
return failure(fault.code, fault.message, scan.path)
return faulted(directive.fault ?? unknownDirectiveFault(directive.value.name), scan.path)
}
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 { Block } from './blocks.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 { parseBlocks } from './blocks.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)
case 'code':
return success(codeBlockNode(block.language, block.text))
case 'directive': {
const fault = unknownDirectiveFault(block.name)
return failure(fault.code, fault.message, path)
}
case 'directive':
return faulted(unknownDirectiveFault(block.name), path)
case 'fault':
return failure(block.fault.code, block.fault.message, path)
return faulted(block.fault, path)
case 'heading':
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path)
case 'html':
+7 -2
View File
@@ -18,12 +18,13 @@ export type ConvertErrorCode =
export type ConvertErrorPath = readonly (number | string)[]
export type ConvertFault = {
export type ConvertError = {
code: ConvertErrorCode
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 }
@@ -31,6 +32,10 @@ export function failure<T>(code: ConvertErrorCode, message: string, path: Conver
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> {
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
MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes
`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
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