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
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.
- A reader takes the text and an index — a sticky regex, `indexOf` — never a fresh slice per
character, and a per-character walk hoists what does not vary with the character. The pipeline
persona feeds documents nobody typed, so an ordinary megabyte stays milliseconds.
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
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
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
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
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
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.
## 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 placements: number[] = []
let output = ''
const linkClose = lastLinkClose(scan, escapings)
let line = scanLine(scan, 0)
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)
@@ -78,7 +79,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
escapable &&
(claimsLineStart(line, index, container) ||
mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, escapings, index, escaping === 'bracketed', container, escaped))
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))
) {
output += '\\'
escaped.add(index)
@@ -185,14 +186,14 @@ function isSyntax(escaping: InlineEscaping | undefined): boolean {
function opensConstruct(
scan: string,
escapings: readonly (InlineEscaping | undefined)[],
linkClose: number,
index: number,
inBrackets: boolean,
container: LineContainer,
escaped: ReadonlySet<number>,
): boolean {
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 {
@@ -211,7 +212,7 @@ function closesHeading(scan: string, index: number): boolean {
function claimsCharacter(
scan: string,
escapings: readonly (InlineEscaping | undefined)[],
linkClose: number,
index: number,
inBrackets: boolean,
container: LineContainer,
@@ -224,19 +225,20 @@ function claimsCharacter(
if (character === '&') return readEntityReference(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 opensLink(scan, escapings, index)
if (character === '[') return index < linkClose
if (character === '`') return opensCodeSpan(scan, index, escaped)
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
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.
function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
for (let cursor = index + 1; cursor < scan.length; cursor += 1) {
function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undefined)[]): number {
for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) {
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 {
+8 -4
View File
@@ -4,14 +4,18 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import { readEntityReference } from './entity-references.ts'
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
// 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', () => {
// A value holding a space or a tilde mis-splits into a wrong name and a lost one, silently, at load.
test('every packed reference parts into one name and one value the table decodes', () => {
const packed = /const packedReferences =\n {2}'([\s\S]*?)'\n/.exec(source)?.[1]
assert.ok(packed !== undefined)
const references = packed.split(/(?:\\\n)? +/)
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)
}
})