Compare commits
3 Commits
main
...
fbc719cca4
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc719cca4 | |||
| cf60738de3 | |||
| 4ad8a9228c |
@@ -77,3 +77,9 @@ test('accepts the JSON values an attribute may hold', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
})
|
||||
|
||||
test("reads a node's siblings as a walk rather than as one call's arguments", () => {
|
||||
const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 }
|
||||
assert.equal(isAdfDocument(wide), true)
|
||||
assert.equal(fault(wide), 'accepted')
|
||||
})
|
||||
|
||||
+2
-2
@@ -96,7 +96,7 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
||||
if ('content' in node) {
|
||||
const content = node['content']
|
||||
if (!Array.isArray(content)) return false
|
||||
pending.push(...content)
|
||||
for (const child of content) pending.push(child)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -109,7 +109,7 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
||||
if (node === undefined) continue
|
||||
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
|
||||
if (fault !== undefined) return fault
|
||||
pending.push(...nodeContent(node))
|
||||
for (const child of nodeContent(node)) pending.push(child)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { readEntityReference, replacementCharacter } from './entity-references.t
|
||||
|
||||
export type LinePosition = 'first' | 'later'
|
||||
|
||||
// The suffix lengths of a text that spell a thematic break, and the marker each of them opens with.
|
||||
export type ThematicBreakTail = { longest: number; marker: string; shortest: number }
|
||||
|
||||
type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
|
||||
|
||||
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
|
||||
@@ -52,6 +55,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [
|
||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
|
||||
const blankLine = /^[ \t]*$/
|
||||
const breakMarkers = '*-_'
|
||||
const codeFenceOpener = /^(`{3,}|~{3,})/
|
||||
const pipeClaim = /^\|/
|
||||
const bulletListOpener = /^[*+-](?:[ \t]|$)/
|
||||
@@ -63,7 +67,6 @@ const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
||||
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
|
||||
const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/
|
||||
const setextUnderline = /^(=+|-+)[ \t]*$/
|
||||
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
|
||||
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
||||
|
||||
export function atxHeading(line: string): { level: number; text: string } | undefined {
|
||||
@@ -118,7 +121,7 @@ export function decodeTextEscapes(text: string): string {
|
||||
|
||||
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
|
||||
if (offset === 0) {
|
||||
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true
|
||||
if (firstCharacterOpeners.some((opener) => opener.test(line)) || isThematicBreak(line)) return true
|
||||
if (openingHtmlBlock(line, position === 'later') !== undefined) return true
|
||||
return position === 'later' && setextUnderline.test(line)
|
||||
}
|
||||
@@ -168,7 +171,33 @@ export function isBlankLine(line: string): boolean {
|
||||
}
|
||||
|
||||
export function isThematicBreak(line: string): boolean {
|
||||
return thematicBreak.test(line)
|
||||
return holdsThematicBreak(thematicBreakTail(line), line)
|
||||
}
|
||||
|
||||
// A break runs to the line's end, so one scan of that run answers every level the list walk opens.
|
||||
export function thematicBreakTail(text: string): ThematicBreakTail | undefined {
|
||||
let marker: string | undefined
|
||||
let markers = 0
|
||||
let first = text.length
|
||||
let third = text.length
|
||||
let cursor = text.length
|
||||
while (cursor > 0) {
|
||||
const character = text.charAt(cursor - 1)
|
||||
if (marker === undefined ? breakMarkers.includes(character) : character === marker) {
|
||||
marker = character
|
||||
markers += 1
|
||||
first = cursor - 1
|
||||
if (markers === 3) third = cursor - 1
|
||||
} else if (!spaceOrTab(character)) break
|
||||
cursor -= 1
|
||||
}
|
||||
if (marker === undefined || markers < 3) return undefined
|
||||
return { longest: text.length - first, marker, shortest: text.length - third }
|
||||
}
|
||||
|
||||
export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: string): boolean {
|
||||
if (tail === undefined) return false
|
||||
return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker
|
||||
}
|
||||
|
||||
export function isUnicodeWhitespace(character: string): boolean {
|
||||
|
||||
@@ -17,7 +17,10 @@ export type DirectiveLine =
|
||||
| { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string }
|
||||
| { kind: 'closer'; name: string }
|
||||
|
||||
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string }
|
||||
// spans: the directives the content holds, at their offset in it, so reading the slot back never scans them again.
|
||||
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string; spans: NestedSpans }
|
||||
|
||||
export type NestedSpans = ReadonlyMap<number, DirectiveSpan>
|
||||
|
||||
export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T }
|
||||
|
||||
@@ -25,7 +28,9 @@ type Attributes = { attributes: DirectiveAttributes; end: number }
|
||||
|
||||
type AttributePair = { end: number; key: string; value: DirectiveValue }
|
||||
|
||||
type Content = { content: string | undefined; end: number }
|
||||
type Content = { content: string | undefined; end: number; spans: NestedSpans }
|
||||
|
||||
type DirectiveContent = { end: number; spans: NestedSpans }
|
||||
|
||||
export const directivePrefix = '!adf:'
|
||||
|
||||
@@ -41,6 +46,8 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
|
||||
const rawReserved = new RegExp(reservedSource)
|
||||
const noAttributes: DirectiveAttributes = new Map()
|
||||
|
||||
export const noSpans: NestedSpans = new Map()
|
||||
|
||||
export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
|
||||
|
||||
const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}`
|
||||
@@ -213,14 +220,14 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
|
||||
if (slot.fault !== undefined) return { fault: slot.fault }
|
||||
const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined)
|
||||
if (attributes.fault !== undefined) return { fault: attributes.fault }
|
||||
return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name } }
|
||||
return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name, spans: slot.value.spans } }
|
||||
}
|
||||
|
||||
function readContentSlot(text: string, index: number, depth: number): Read<Content> {
|
||||
if (text.charAt(index) !== '[') return { value: { content: undefined, end: index } }
|
||||
if (text.charAt(index) !== '[') return { value: { content: undefined, end: index, spans: noSpans } }
|
||||
const close = readDirectiveContent(text, index + 1, depth)
|
||||
if (close.fault !== undefined) return { fault: close.fault }
|
||||
return { value: { content: text.slice(index + 1, close.value), end: close.value + 1 } }
|
||||
return { value: { content: text.slice(index + 1, close.value.end), end: close.value.end + 1, spans: close.value.spans } }
|
||||
}
|
||||
|
||||
function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
|
||||
@@ -231,7 +238,8 @@ function readAttributesAt(text: string, index: number, braceClaims: boolean): Re
|
||||
}
|
||||
|
||||
// A code span, an escape and a nested directive each bind before the content's own closing bracket.
|
||||
function readDirectiveContent(text: string, start: number, depth: number): Read<number> {
|
||||
function readDirectiveContent(text: string, start: number, depth: number): Read<DirectiveContent> {
|
||||
const spans = new Map<number, DirectiveSpan>()
|
||||
let brackets = 0
|
||||
let cursor = start
|
||||
while (cursor < text.length && text.charAt(cursor) !== '\n') {
|
||||
@@ -246,13 +254,13 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
|
||||
cursor = span
|
||||
continue
|
||||
}
|
||||
const nested = readNestedDirective(text, cursor, depth + 1)
|
||||
const nested = keepNestedSpan(text, cursor, depth, start, spans)
|
||||
if (nested?.fault !== undefined) return { fault: nested.fault }
|
||||
if (nested !== undefined) {
|
||||
if (nested.fault !== undefined) return { fault: nested.fault }
|
||||
cursor += nested.value.length
|
||||
cursor = nested.value
|
||||
continue
|
||||
}
|
||||
if (character === ']' && brackets === 0) return { value: cursor }
|
||||
if (character === ']' && brackets === 0) return { value: { end: cursor, spans } }
|
||||
if (character === '[') brackets += 1
|
||||
if (character === ']') brackets -= 1
|
||||
cursor += 1
|
||||
@@ -260,6 +268,14 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
|
||||
return { fault: malformedDirective(`an inline directive [content] is unclosed; ${directiveEscape}`) }
|
||||
}
|
||||
|
||||
function keepNestedSpan(text: string, cursor: number, depth: number, start: number, spans: Map<number, DirectiveSpan>): Read<number> | undefined {
|
||||
const nested = readNestedDirective(text, cursor, depth + 1)
|
||||
if (nested === undefined) return undefined
|
||||
if (nested.fault !== undefined) return { fault: nested.fault }
|
||||
spans.set(cursor - start, nested.value)
|
||||
return { value: cursor + nested.value.length }
|
||||
}
|
||||
|
||||
// `undefined` where the span crosses the line ending an inline directive may not cross.
|
||||
function readCodeSpanEnd(text: string, index: number): number | undefined {
|
||||
const opener = backtickRun(text, index)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
||||
import { carriedInline } from '../opaque-carry.ts'
|
||||
import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts'
|
||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark-grammar.ts'
|
||||
import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -129,8 +129,8 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean)
|
||||
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
||||
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
||||
const body = segment.text.slice(head.length)
|
||||
const tail = trailing ? (/[ \t]+$/.exec(body)?.[0] ?? '') : ''
|
||||
const middle = body.slice(0, body.length - tail.length)
|
||||
const middle = trailing ? trimTrailingSpace(body) : body
|
||||
const tail = body.slice(middle.length)
|
||||
const edges: InlineSegment[] = []
|
||||
if (head !== '') edges.push(carriedText(head))
|
||||
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
||||
|
||||
@@ -17,10 +17,10 @@ export function readLabel(text: string, offset: number): LinkPart | undefined {
|
||||
}
|
||||
|
||||
export function normalizeLabel(raw: string): string {
|
||||
return raw
|
||||
.replace(/^[ \t\n]+|[ \t\n]+$/g, '')
|
||||
.replace(/[ \t\n]+/g, ' ')
|
||||
.toLowerCase()
|
||||
const collapsed = raw.replace(/[ \t\n]+/g, ' ')
|
||||
const start = collapsed.startsWith(' ') ? 1 : 0
|
||||
const end = collapsed.endsWith(' ') ? collapsed.length - 1 : collapsed.length
|
||||
return collapsed.slice(start, end).toLowerCase()
|
||||
}
|
||||
|
||||
export function readDestination(text: string, offset: number): LinkPart | undefined {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
claimsPipeLine,
|
||||
closingCodeFence,
|
||||
decodeTextEscapes,
|
||||
holdsThematicBreak,
|
||||
isBlankLine,
|
||||
isThematicBreak,
|
||||
listMarker,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
openingHtmlBlock,
|
||||
replaceNullCharacters,
|
||||
setextHeadingLevel,
|
||||
thematicBreakTail,
|
||||
type ThematicBreakTail,
|
||||
} from '../commonmark-grammar.ts'
|
||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
||||
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
|
||||
@@ -143,10 +146,11 @@ function blockquoteRest(opener: Line): Line | undefined {
|
||||
|
||||
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
|
||||
const unmatched = walk.stack[depth]
|
||||
const tail = thematicBreakTail(line.text)
|
||||
let opened = false
|
||||
let rest = line
|
||||
while (leadingColumns(rest) < indentedCodeColumns) {
|
||||
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
|
||||
const start = containerStart(rest, tail, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
|
||||
if (start === undefined) break
|
||||
if (!opened) closeContainers(walk, depth)
|
||||
opened = true
|
||||
@@ -156,11 +160,17 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
|
||||
return { opened, rest }
|
||||
}
|
||||
|
||||
function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined {
|
||||
function containerStart(
|
||||
line: Line,
|
||||
tail: ThematicBreakTail | undefined,
|
||||
paragraphOpen: boolean,
|
||||
enclosing: OpenContainer | undefined,
|
||||
position: SourcePosition,
|
||||
): ContainerStart | undefined {
|
||||
const opener = removeColumns(line, largestOpenerIndentation)
|
||||
const blockquote = blockquoteRest(opener)
|
||||
if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote }
|
||||
if (isThematicBreak(opener.text)) return undefined
|
||||
if (holdsThematicBreak(tail, opener.text)) return undefined
|
||||
return itemStart(line, opener, paragraphOpen, enclosing, position)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { DirectiveSpan } from '../directive-syntax.ts'
|
||||
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
||||
import type { EmphasisPairing } from '../emphasis-matching.ts'
|
||||
import type { LineContainer } from '../emit/line-escaping.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
@@ -15,7 +15,7 @@ import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
||||
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
||||
import { readCarriedInline } from '../opaque-carry.ts'
|
||||
import { readDirectiveMark } from './directive-marks.ts'
|
||||
import { readInlineDirective } from '../directive-syntax.ts'
|
||||
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||
import { readTextDirective } from '../text-directive.ts'
|
||||
|
||||
@@ -45,6 +45,7 @@ type Scan = {
|
||||
pending: string
|
||||
pieces: Piece[]
|
||||
source: string
|
||||
spans: NestedSpans
|
||||
}
|
||||
|
||||
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
||||
@@ -54,11 +55,11 @@ const imageAlone = 'an image fits only as a paragraph of its own: this one sits
|
||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
|
||||
return parseInline(source, definitions, path, container)
|
||||
return parseInline(source, definitions, path, container, noSpans)
|
||||
}
|
||||
|
||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined): Result<InlineContent> {
|
||||
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source }
|
||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
|
||||
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
switch (source.charAt(index)) {
|
||||
@@ -168,14 +169,20 @@ function openBracket(scan: Scan, index: number): number {
|
||||
}
|
||||
|
||||
function readDirective(scan: Scan, index: number): Result<number> | undefined {
|
||||
const held = scan.spans.get(index)
|
||||
if (held !== undefined) return pushDirective(scan, held, index)
|
||||
const directive = readInlineDirective(scan.source, index)
|
||||
if (directive === undefined) return undefined
|
||||
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
|
||||
const piece = directivePiece(scan, directive.value, index)
|
||||
return pushDirective(scan, directive.value, index)
|
||||
}
|
||||
|
||||
function pushDirective(scan: Scan, span: DirectiveSpan, index: number): Result<number> {
|
||||
const piece = directivePiece(scan, span, index)
|
||||
if (!piece.ok) return piece
|
||||
flush(scan, false)
|
||||
scan.pieces.push(piece.value)
|
||||
return success(index + directive.value.length)
|
||||
return success(index + span.length)
|
||||
}
|
||||
|
||||
function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
|
||||
@@ -187,7 +194,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
||||
const text = readTextDirective(span)
|
||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
||||
const slot = slotContent(scan, span.content)
|
||||
const slot = slotContent(scan, span)
|
||||
if (!slot.ok) return slot
|
||||
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
||||
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
||||
@@ -216,9 +223,9 @@ function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
||||
return undefined
|
||||
}
|
||||
|
||||
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
|
||||
if (content === undefined) return success(undefined)
|
||||
const parsed = parseInline(content, scan.definitions, scan.path, undefined)
|
||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||
if (span.content === undefined) return success(undefined)
|
||||
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans)
|
||||
if (!parsed.ok) return parsed
|
||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success(parsed.value)
|
||||
|
||||
@@ -504,6 +504,36 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
|
||||
counting the directive form once for doubling the parser's frames per level.
|
||||
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
||||
for the emit, so its two walks stay parted.
|
||||
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
||||
its run from every start position, so an interior whitespace run costs quadratic time rather
|
||||
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
||||
replaced the regex. The sites the same sweep did not reach: `normalizeLabel` in
|
||||
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
||||
999-capped `readLabel` value, and `carryEdges` in `emit/inline-line.ts`. A third of another
|
||||
shape joins them: `readNestedDirective` restarts its depth counter per level, so each parse
|
||||
level re-scans the region below it and nested inline directives cost O(depth × content),
|
||||
bounded by the 500-level guard. A fourth predates 12c: the list-item walk re-scans the rest
|
||||
of a line once per item level, 30000 nested items taking 4.4s at 59 KB (the
|
||||
stability-reviewer, 2026-09-16). §11's scanning rule is the whole argument; the pipeline
|
||||
persona feeds documents nobody typed. A fifth is a throw rather than a cost:
|
||||
`adfDocumentFault` pushes a node's content with a spread, so past about 125k sibling nodes
|
||||
the guard throws a `RangeError` where §11 owes a `Result` (the stability-reviewer and the
|
||||
maintainer, 2026-09-18).
|
||||
**Settled** (the maintainer, 2026-09-18): the five sites land in one PR rather than split
|
||||
into sub-items, and a behaviour-preserving cost fix is accepted on the suite staying green
|
||||
with no fixture output changed, plus the measurement below — §14 promises no figure, so
|
||||
nothing times the gate. The guard's spread is the one behavioural fix and carries a test.
|
||||
**Corrected** (2026-09-18): the entry filed two sites in `emit/inline-line.ts` on 2026-09-01
|
||||
and the file has changed since — `tryImageLine`'s alternation measures linear (3.4 / 1.9 /
|
||||
5.3 ms over 10k / 20k / 40k spaces), leaving `carryEdges`' trailing trim the only one.
|
||||
**Measured** (2026-09-18), each at the size its filing named: `normalizeLabel` 1026 ms → 5 ms
|
||||
at 40k interior spaces, `carryEdges` 1024 ms → 7 ms (its heading path 978 ms → 5 ms),
|
||||
`readNestedDirective` 434 ms → 10 ms at 397 kB and 200 levels, the list-item walk 4196 ms →
|
||||
39 ms at 30000 items, and the document guard a `RangeError` → 42 ms at 200k siblings under
|
||||
one node. The list-item walk's mixed-marker shape, which the fix had to answer too, reads
|
||||
38 ms where the tail scan alone would have left it quadratic.
|
||||
The sixth site the sweep found went to 18 rather than landing here (the maintainer,
|
||||
2026-09-18).
|
||||
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
||||
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
||||
remote, the README title, §6's published-as line, the checkout directory.
|
||||
|
||||
@@ -6,7 +6,7 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
|
||||
## Milestones
|
||||
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, 4c, 14, 15, 16, 10, 5g → `0.2.0`;
|
||||
4d, 5f → `0.2.1`; 6, 7 → `0.3.0`; 9, 17 → TBD; 5e last.
|
||||
4d, 5f, 18 → `0.2.1`; 6, 7 → `0.3.0`; 9, 17 → TBD; 5e last.
|
||||
The numbering is the order the work was planned in, not the order it ships. `0.2.0`'s order is settled
|
||||
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
|
||||
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
|
||||
@@ -49,32 +49,7 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
||||
- [x] **4.3 — The markdown property.**
|
||||
- [x] **4.4 — The real payloads.**
|
||||
- [x] **4b — The block walk's retry (`0.2.0`).**
|
||||
- [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
||||
its run from every start position, so an interior whitespace run costs quadratic time rather
|
||||
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
||||
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
|
||||
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
||||
999-capped `readLabel` value, and two in `emit/inline-line.ts`. The fix is the one 3h used —
|
||||
an index walk, `trimTrailingSpace` where the ends match. A fourth of another shape joins
|
||||
them: `readNestedDirective` restarts its depth counter per level, so each parse level
|
||||
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
|
||||
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
|
||||
guard. A fifth predates 12c: the list-item walk re-scans the rest of a line once per item
|
||||
level — `isThematicBreak` in `containerStart` on an opener line, `isBlankLine` and
|
||||
`leadingColumns` in `continuesContainer` on a continuation line, and a blank line
|
||||
continues every open item without consuming input; 30000 nested items take 4.4s at 59 KB
|
||||
(the stability-reviewer, 2026-09-16). §11's scanning rule is
|
||||
the whole argument; the pipeline persona feeds documents nobody typed.
|
||||
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
|
||||
complexity (the maintainer, 2026-09-16). A sixth 4b leaves behind: the parser asks
|
||||
`commonMarkSpelling` at every directive-spelled list it reads, and the answer spells the whole
|
||||
subtree below, itself quadratic in the depth left, so nested directive lists cost about the
|
||||
cube of their depth — 250 rule-first levels parse in 1.3 s at 16.5 kB, 1.5 MB of that shape
|
||||
at 250 levels in 0.6 s — bounded by the depth guard like `readNestedDirective` (the
|
||||
maintainer, 2026-09-18). A seventh is a throw rather than a cost: `adfDocumentFault` pushes a
|
||||
node's content with a spread, so past about 125k sibling nodes the guard throws a
|
||||
`RangeError` where §11 owes a `Result` — a loop over the content closes it (the
|
||||
stability-reviewer and the maintainer, 2026-09-18).
|
||||
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
|
||||
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
|
||||
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
||||
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
||||
@@ -235,6 +210,22 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
||||
branch count or size — so the fits-in-your-head guardrail fails the build rather than
|
||||
waiting for a review to catch it (the systems-architect, 2026-09-16); placed after `0.3.0`
|
||||
(the maintainer, 2026-09-17).
|
||||
- [ ] **18 — The subtree the directive spelling asks about (`0.2.1`).** The parser asks
|
||||
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree
|
||||
below, so a node at depth d is spelled d times: three nested rule-first directive lists cost
|
||||
18 asks over 10 nodes, and 250 levels parse in 1.2 s at 16.4 kB, 4.9 s at 261 kB with a
|
||||
kilobyte of content per level. The depth guard bounds the levels at about 250, never the
|
||||
content, so this is the pipeline persona's hang on an input nobody typed (§11). Keeping each
|
||||
child's emitted result for its parent's ask is not a straight handover: the same node object
|
||||
is asked at different depths — 4, 3 and 2 for the innermost list of three — because the
|
||||
parser counts a list and its item as two levels where the emitter's readable list counts one
|
||||
(4b), and `headroom` is that guard's slack. The parts that survive the measurement: the paths
|
||||
agree, `text` and `spelling` carry no depth, `headroom` is affine in it, and the parser asks
|
||||
first at the deepest of them, so a kept result rebases by the difference. Either rebase and
|
||||
record that argument in `AGENTS.md`, or give both directions one list accounting so a node
|
||||
has one depth and nothing needs rebasing — which reopens 4b. A single post-build walk was
|
||||
rejected: it reports the outer offender where the build reports the inner one (the
|
||||
maintainer, 2026-09-18).
|
||||
|
||||
## The ADF inventory to cover
|
||||
|
||||
|
||||
Reference in New Issue
Block a user