Answer the stability review: the link close hoists out of the character walk
CI / gate (push) Successful in 4s

This commit is contained in:
2026-08-30 23:22:52 +02:00
parent 60792bceac
commit 5c83679dc8
3 changed files with 24 additions and 17 deletions
+5 -4
View File
@@ -138,9 +138,10 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
`Result` rather than the stack overflow that waits near 2000. `Result` rather than the stack overflow that waits near 2000.
- A reader takes the text and an index — a sticky regex, `indexOf` — never a fresh slice per - A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
character, and a per-character walk hoists what does not vary with the character. The pipeline line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
persona feeds documents nobody typed, so an ordinary megabyte stays milliseconds. hoists the scan that does not vary with the character. The pipeline persona feeds documents
nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond.
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable. unrepresentable.
@@ -180,7 +181,7 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
validation or exported validator — a refusal that keeps the round-trip is not schema validation, validation or exported validator — a refusal that keeps the round-trip is not schema validation,
so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no
streaming APIs, no performance budget past §11's linear scan — nothing here is tuned, and no streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal. figure is promised. A CLI is a later goal (`todo.md`), not a non-goal.
## 15. The working loop ## 15. The working loop
+11 -9
View File
@@ -69,6 +69,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
const escaped = new Set<number>() const escaped = new Set<number>()
const placements: number[] = [] const placements: number[] = []
let output = '' let output = ''
const linkClose = lastLinkClose(scan, escapings)
let line = scanLine(scan, 0) let line = scanLine(scan, 0)
for (let index = 0; index < scan.length; index += 1) { for (let index = 0; index < scan.length; index += 1) {
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1) if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
@@ -78,7 +79,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
escapable && escapable &&
(claimsLineStart(line, index, container) || (claimsLineStart(line, index, container) ||
mergesWithSyntax(scan, escapings, index) || mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, escapings, index, escaping === 'bracketed', container, escaped)) opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))
) { ) {
output += '\\' output += '\\'
escaped.add(index) escaped.add(index)
@@ -185,14 +186,14 @@ function isSyntax(escaping: InlineEscaping | undefined): boolean {
function opensConstruct( function opensConstruct(
scan: string, scan: string,
escapings: readonly (InlineEscaping | undefined)[], linkClose: number,
index: number, index: number,
inBrackets: boolean, inBrackets: boolean,
container: LineContainer, container: LineContainer,
escaped: ReadonlySet<number>, escaped: ReadonlySet<number>,
): boolean { ): boolean {
if (container === 'heading' && closesHeading(scan, index)) return true if (container === 'heading' && closesHeading(scan, index)) return true
return claimsCharacter(scan, escapings, index, inBrackets, container, escaped) return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped)
} }
function claimsLineStart(line: ScanLine, index: number, container: LineContainer): boolean { function claimsLineStart(line: ScanLine, index: number, container: LineContainer): boolean {
@@ -211,7 +212,7 @@ function closesHeading(scan: string, index: number): boolean {
function claimsCharacter( function claimsCharacter(
scan: string, scan: string,
escapings: readonly (InlineEscaping | undefined)[], linkClose: number,
index: number, index: number,
inBrackets: boolean, inBrackets: boolean,
container: LineContainer, container: LineContainer,
@@ -224,19 +225,20 @@ function claimsCharacter(
if (character === '&') return readEntityReference(scan, index) !== undefined if (character === '&') return readEntityReference(scan, index) !== undefined
if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined
if (character === ':') return opensInlineDirective(scan, index) if (character === ':') return opensInlineDirective(scan, index)
if (character === '[') return opensLink(scan, escapings, index) if (character === '[') return index < linkClose
if (character === '`') return opensCodeSpan(scan, index, escaped) if (character === '`') return opensCodeSpan(scan, index, escaped)
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped) if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
return false return false
} }
// The last `]` link text could close on, so a `[` ahead of it opens one and a `[` past it cannot.
// A `]` the emitter spelled sits inside a construct that binds before link text does. // A `]` the emitter spelled sits inside a construct that binds before link text does.
function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean { function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undefined)[]): number {
for (let cursor = index + 1; cursor < scan.length; cursor += 1) { for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) {
if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue
if (followsLinkText.test(scan.charAt(cursor + 1))) return true if (followsLinkText.test(scan.charAt(cursor + 1))) return cursor
} }
return false return -1
} }
function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean { function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
+8 -4
View File
@@ -4,14 +4,18 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { readEntityReference } from './entity-references.ts'
const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'entity-references.ts'), 'utf8') const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'entity-references.ts'), 'utf8')
// The packing the module rests on, read off the rows rather than trusted: a value holding a space or a // A value holding a space or a tilde mis-splits into a wrong name and a lost one, silently, at load.
// tilde mis-splits into a wrong name and a lost one, at load, with nothing failing. test('every packed reference parts into one name and one value the table decodes', () => {
test('every packed reference parts into one name and one value', () => {
const packed = /const packedReferences =\n {2}'([\s\S]*?)'\n/.exec(source)?.[1] const packed = /const packedReferences =\n {2}'([\s\S]*?)'\n/.exec(source)?.[1]
assert.ok(packed !== undefined) assert.ok(packed !== undefined)
const references = packed.split(/(?:\\\n)? +/) const references = packed.split(/(?:\\\n)? +/)
assert.equal(references.length, 2125) assert.equal(references.length, 2125)
for (const reference of references) assert.match(reference, /^[A-Za-z][A-Za-z0-9]*~[^ ~]+$/) for (const reference of references) {
assert.match(reference, /^[A-Za-z][A-Za-z0-9]*~[^ ~]+$/)
assert.ok(readEntityReference(`&${reference.slice(0, reference.indexOf('~'))};`, 0) !== undefined)
}
}) })