5c: report the depth cause from the guards, and make the publish converge
CI / gate (push) Successful in 7m12s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-03 21:19:01 +02:00
parent d9056973a1
commit 0dfcc0f9ca
19 changed files with 191 additions and 105 deletions
+3
View File
@@ -9,6 +9,9 @@ jobs:
- run: bash ci.sh
publish:
concurrency:
cancel-in-progress: false
group: publish
if: github.ref == 'refs/heads/main'
needs: gate
runs-on: docker-host
+23 -8
View File
@@ -131,10 +131,15 @@ descends, so a document reports its first error in document order. `not-an-adf-d
the document's own path throughout: eight of the guard's nine branches read the document's own
shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names
the violation instead. Depth is not one of the nine: the guard runs a second time unbounded, so an
attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as it already is
from the parser, and both directions refuse the same value — the guard counts the levels an
attribute holds, never the `attrs` object holding it.
the violation instead. Depth is not one of the nine: `adfDocumentFault` returns the code with the
message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as
it already is from the parser, both directions refusing the same value — the count is the levels
an attribute holds, never the `attrs` object holding it. `isAdfDocument` is true for a depth fault:
a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already
waves through are, and depth is the walks' answer rather than the shape's. A non-finite number
stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is
at fault, the emit `not-an-adf-document` because the input is, and unlike depth nothing round-trips
inconsistently between them.
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
@@ -156,6 +161,13 @@ wide `Result<T>`, since half their refusals come from an emit stage that read no
deliberate semver judgment. `publish.sh` is that job, and `private: true` stops it before it
reads the token, so the pipeline is live and silent until the maintainer's first bump drops the
field.
- The publish and the tag each observe their own end state — the version on npm, the tag on the
remote — and neither gates the other, so a run that dies between them converges on the next push
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
deterministic, so the two builds agree, and promoting an artifact would make the release path
depend on a store that the gate would then have to keep.
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
@@ -175,10 +187,13 @@ emphasis matching leans on can disagree. Both refuse a run matching no test, so
vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price
of proving those engines over the corpus rather than over a smoke import.
The gate then builds and runs `package-tests/` against what it built, reached by the package's own
name so `exports` answers: `consumer.ts` typechecks the emitted `.d.ts` from outside
`tsconfig.build.json`, since declaration emit leaves `.ts` specifiers a consumer's resolver must
map itself, and `node-floor.js` round-trips under a Node pinned to `engines.node`'s floor.
The gate then packs the build and installs the tarball under `package-tests/`, so `files`,
`exports` and `types` are proved on the artifact that ships rather than on the source tree a
self-reference would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
+5 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source ./docker-images.sh
source ./docker-runner.sh
in_image "$node_image" npm ci
in_image "$node_image" npm run typecheck
@@ -20,5 +20,9 @@ in_image "$deno_image" deno test --allow-read --no-check src/
in_image "$bun_image" bun test src/
in_image "$node_image" npm run build
in_image "$node_image" sh -c 'set -e
rm -rf package-tests/node_modules
npm pack --pack-destination /tmp >/dev/null
npm install --no-save --no-package-lock --prefix package-tests /tmp/*.tgz >/dev/null'
in_image "$node_image" npx tsc -p package-tests
in_image "$floor_image" node package-tests/node-floor.js
+1 -1
View File
@@ -6,5 +6,5 @@ node_image=node:24.19.0-alpine3.24
in_image() {
local image=$1 entrypoint=$2
shift 2
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "adf-codec-package-tests",
"private": true,
"type": "module"
}
+1
View File
@@ -18,6 +18,7 @@
"files": [
"dist"
],
"sideEffects": false,
"engines": {
"node": ">=18"
},
+15 -8
View File
@@ -1,28 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source ./docker-images.sh
source ./docker-runner.sh
read_field() {
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
}
if [ "$(read_field private)" = 'true' ]; then
published_version() {
in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true
}
private=$(read_field private)
if [ "$private" = 'true' ]; then
echo 'package.json is private — the maintainer removes that in the bump that first publishes'
exit 0
fi
name=$(read_field name)
version=$(read_field version)
published=$(in_image "$node_image" npm view "$name@latest" version 2>/dev/null || true)
if [ "$version" = "$published" ]; then
echo "npm holds $name $version already — no bump, no deploy"
exit 0
fi
# Both steps observe their own end state, so a partial run converges on the next push to main.
if [ -z "$(published_version "$name" "$version")" ]; then
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
in_image "$node_image" npm ci
in_image "$node_image" npm run build
in_image "$node_image" sh -c 'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
fi
if [ -z "$(git ls-remote --tags origin "v$version")" ]; then
git tag "v$version"
git push origin "v$version"
fi
+4 -4
View File
@@ -7,7 +7,7 @@
"datasourceTemplate": "docker",
"depNameTemplate": "denoland/deno",
"description": "Pin the Deno image the gate runs",
"managerFilePatterns": ["docker-images.sh"],
"managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
@@ -16,7 +16,7 @@
"datasourceTemplate": "docker",
"depNameTemplate": "node",
"description": "Pin the node image the gate runs",
"managerFilePatterns": ["docker-images.sh"],
"managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["node_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
@@ -25,7 +25,7 @@
"datasourceTemplate": "docker",
"depNameTemplate": "node-floor",
"description": "Pin the node image proving engines.node, held to that major",
"managerFilePatterns": ["docker-images.sh"],
"managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["floor_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
"packageNameTemplate": "node",
"versioningTemplate": "docker"
@@ -35,7 +35,7 @@
"datasourceTemplate": "docker",
"depNameTemplate": "oven/bun",
"description": "Pin the Bun image the gate runs",
"managerFilePatterns": ["docker-images.sh"],
"managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
+13 -7
View File
@@ -6,7 +6,11 @@ import { adfDocumentFault, isAdfDocument } from './document.ts'
import { largestNesting } from '../nesting.ts'
function fault(value: unknown): string {
return adfDocumentFault(value) ?? 'accepted'
return adfDocumentFault(value)?.message ?? 'accepted'
}
function faultCode(value: unknown): string {
return adfDocumentFault(value)?.code ?? 'accepted'
}
function nested(levels: number): JsonValue {
@@ -56,13 +60,15 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false)
})
test('holds an attribute value to the levels the parser reads one at, the attrs object costing none', () => {
assert.equal(isAdfDocument(withAttribute(nested(largestNesting))), true)
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), false)
assert.equal(adfDocumentFault(withAttribute(nested(largestNesting + 1)), Number.POSITIVE_INFINITY), undefined)
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
const marked = { content: [{ marks: [{ attrs: { a: nested(largestNesting + 1) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }
assert.equal(isAdfDocument(marked), false)
assert.equal(adfDocumentFault(marked, Number.POSITIVE_INFINITY), undefined)
assert.equal(fault(marked), deeper('a', 'link'))
assert.equal(isAdfDocument(marked), true)
})
test('accepts the JSON values an attribute may hold', () => {
+60 -21
View File
@@ -1,4 +1,5 @@
import { isJsonValue, type JsonValue } from '../json-value.ts'
import type { ConvertFault } from '../result.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
export type AdfAttributes = { [key: string]: JsonValue }
@@ -26,19 +27,25 @@ const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
export function adfDocumentFault(value: unknown, levels: number = largestNesting): string | undefined {
if (!isRecord(value)) return `an ADF document is an object: found ${describe(value)}`
export function adfDocumentFault(value: unknown): ConvertFault | undefined {
if (!isRecord(value)) return notADocument(`an ADF document is an object: found ${describe(value)}`)
const extra = extraKey(value, documentKeys)
if (extra !== undefined) return `an ADF document holds content, type and version alone: found the key ${extra}`
if (!('type' in value)) return 'an ADF document holds type "doc": found no type field'
if (value['type'] !== 'doc') return `an ADF document holds type "doc": found ${describe(value['type'])}`
if (!('version' in value)) return 'an ADF document holds a version number: found no version field'
if (extra !== undefined) return notADocument(`an ADF document holds content, type and version alone: found the key ${extra}`)
if (!('type' in value)) return notADocument('an ADF document holds type "doc": found no type field')
if (value['type'] !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(value['type'])}`)
if (!('version' in value)) return notADocument('an ADF document holds a version number: found no version field')
const version = value['version']
if (typeof version !== 'number' || !Number.isFinite(version)) return `an ADF document holds a version number: found ${describe(version)}`
if (typeof version !== 'number' || !Number.isFinite(version)) return notADocument(`an ADF document holds a version number: found ${describe(version)}`)
if (!('content' in value)) return undefined
const content = value['content']
if (!Array.isArray(content)) return `an ADF document's content is an array: found ${describe(content)}`
return isNodeArray(content, levels) ? undefined : "an ADF document's content holds ADF nodes: one of them is not"
const held: unknown = value['content']
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
const content: readonly unknown[] = held
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return nestingFault(content)
}
export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
}
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
@@ -46,28 +53,30 @@ export function carriesOnly(node: AdfNode, attributes: readonly string[]): boole
return holdsOnly(node.attrs ?? {}, attributes)
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
export function isAdfDocument(value: unknown): value is AdfDocument {
return adfDocumentFault(value) === undefined
const fault = adfDocumentFault(value)
return fault === undefined || fault.code === 'unsupported-nesting-depth'
}
export function isAdfNode(value: unknown): value is AdfNode {
return isNodeArray([value], largestNesting)
return isNodeArray([value])
}
export function isAdfMark(value: unknown, levels: number = largestNesting): value is AdfMark {
export function isAdfMark(value: unknown): value is AdfMark {
if (!isRecord(value) || !holdsOnly(value, markKeys)) return false
if (typeof value['type'] !== 'string') return false
return !('attrs' in value) || isAttributes(value['attrs'], levels)
return !('attrs' in value) || isAttributes(value['attrs'])
}
function isNodeArray(value: readonly unknown[], levels: number): boolean {
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value]
while (pending.length > 0) {
const node = pending.pop()
if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false
if (typeof node['type'] !== 'string') return false
if ('attrs' in node && !isAttributes(node['attrs'], levels)) return false
if ('marks' in node && !isArrayOf(node['marks'], (mark): mark is AdfMark => isAdfMark(mark, levels))) return false
if ('attrs' in node && !isAttributes(node['attrs'])) return false
if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false
if ('text' in node && typeof node['text'] !== 'string') return false
if ('content' in node) {
const content = node['content']
@@ -78,19 +87,49 @@ function isNodeArray(value: readonly unknown[], levels: number): boolean {
return true
}
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
const pending: AdfNode[] = [...nodes]
while (pending.length > 0) {
const node = pending.pop()
if (node === undefined) continue
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks)
if (fault !== undefined) return fault
pending.push(...(node.content ?? []))
}
return undefined
}
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
for (const mark of marks ?? []) {
const fault = attributesFault(mark.attrs, mark.type)
if (fault !== undefined) return fault
}
return undefined
}
function attributesFault(attrs: AdfAttributes | undefined, type: string): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs ?? {})) {
if (overNested(value)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type) }
}
return undefined
}
function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
return Array.isArray(value) && [...value].every(guard)
}
// Per value, so an attribute reaches the same 500 levels the parser reads one at (AGENTS.md §11).
function isAttributes(value: unknown, levels: number): value is AdfAttributes {
return isRecord(value) && Object.values(value).every((held) => isJsonValue(held, levels))
function isAttributes(value: unknown): value is AdfAttributes {
return isRecord(value) && isJsonValue(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function notADocument(message: string): ConvertFault {
return { code: 'not-an-adf-document', message }
}
function describe(value: unknown): string {
if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}` : value)
if (typeof value === 'function') return 'a function'
+18 -8
View File
@@ -2,22 +2,32 @@ import { largestNesting } from './nesting.ts'
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
export function isJsonValue(value: unknown, levels: number = largestNesting): value is JsonValue {
const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }]
export function isJsonValue(value: unknown): value is JsonValue {
const pending: unknown[] = [value]
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
const { depth, item } = entry
if (depth > levels) return false
const item = pending.pop()
if (item === null || typeof item === 'boolean' || typeof item === 'string') continue
if (typeof item === 'number') {
if (!Number.isFinite(item)) return false
continue
}
// A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them.
if (Array.isArray(item)) for (const child of [...item]) pending.push({ depth: depth + 1, item: child })
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
if (Array.isArray(item)) for (const child of [...item]) pending.push(child)
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push(child)
else return false
}
return true
}
export function overNested(value: JsonValue, levels: number = largestNesting): boolean {
const pending: { depth: number; item: JsonValue }[] = [{ depth: 0, item: value }]
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
const { depth, item } = entry
if (depth > levels) return true
if (Array.isArray(item)) for (const child of item) pending.push({ depth: depth + 1, item: child })
else if (item !== null && typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
}
return false
}
+11 -14
View File
@@ -3,11 +3,13 @@ import type { ConvertFault } from '../result.ts'
import type { JsonValue } from '../json-value.ts'
import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from './backtick-runs.ts'
import { isJsonValue } from '../json-value.ts'
import { isJsonValue, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { runLength } from './emphasis-matching.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue }
export type DirectiveValue = { decoded: string; spelling: string }
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
@@ -45,18 +47,13 @@ const orderFault = 'the {attrs} keys read in alphabetical order'
const pairFault = 'an attribute reads key=value, the value bare or double-quoted: this one does not'
const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}`
export function attributeNestingFault(text: string, kind: AttributeKind, key: string, type: string): ConvertFault | undefined {
if (kind !== 'json' || parseJson(text, Number.POSITIVE_INFINITY) === undefined) return undefined
return { code: 'unsupported-nesting-depth', message: `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels the parser carries` }
}
export function attributeValue(text: string, kind: AttributeKind): VocabularyValue | undefined {
if (kind === 'string') return { kind, value: text }
if (kind === 'boolean') return text === 'true' || text === 'false' ? { kind, value: text === 'true' } : undefined
export function attributeValue(text: string, kind: AttributeKind): AttributeReading {
if (kind === 'string') return { value: { kind, value: text } }
if (kind === 'boolean') return text === 'true' || text === 'false' ? { value: { kind, value: text === 'true' } } : { refusal: 'kind' }
const parsed = parseJson(text)
if (parsed === undefined) return undefined
if (kind === 'json') return { kind, value: parsed }
return typeof parsed === 'number' ? { kind, value: parsed } : undefined
if (parsed === undefined) return { refusal: 'kind' }
if (kind === 'number') return typeof parsed === 'number' ? { value: { kind, value: parsed } } : { refusal: 'kind' }
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
}
export function isBareToken(text: string): boolean {
@@ -299,10 +296,10 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value
return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
}
function parseJson(raw: string, levels: number = largestNesting): JsonValue | undefined {
function parseJson(raw: string): JsonValue | undefined {
try {
const value: unknown = JSON.parse(raw)
return isJsonValue(value, levels) ? value : undefined
return isJsonValue(value) ? value : undefined
} catch {
return undefined
}
+4 -3
View File
@@ -336,14 +336,15 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
const deeper = `unsupported-nesting-depth: an attribute value nests deeper than the ${largestNesting} levels the emitter carries`
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper)
const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
const card = (levels: number): AdfNode => {
let data: JsonValue = 1
for (let level = 0; level < levels; level += 1) data = [data]
return { attrs: { data, url: 'https://example.com/a' }, type: 'inlineCard' }
}
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper)
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
const spelled = adfToMarkdown(document(paragraph(card(largestNesting))))
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
+2 -5
View File
@@ -4,7 +4,7 @@ import { adfDocumentFault, carriesOnly } from '../../adf/document.ts'
import { blockDirective } from '../../adf/block-directives.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../backtick-runs.ts'
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
import { languageSlot } from '../code-language.ts'
@@ -24,10 +24,7 @@ const largestListMarker = 999999999
export function adfToMarkdown(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document)
if (fault !== undefined && adfDocumentFault(document, Number.POSITIVE_INFINITY) === undefined) {
return failure('unsupported-nesting-depth', `an attribute value nests deeper than the ${largestNesting} levels the emitter carries`, [])
}
if (fault !== undefined) return failure('not-an-adf-document', fault, [])
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
if (!blocks.ok) return blocks
+4 -5
View File
@@ -3,7 +3,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts'
import { isJsonValue, overNested } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
@@ -37,7 +37,7 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
}
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<string> {
if (!isJsonValue(node, levels)) {
if (!isJsonValue(node) || overNested(node, levels)) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
}
return success(serializeCanonicalJson(node, spelling))
@@ -47,9 +47,8 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): R
const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
const { value } = parsed
if (!isJsonValue(value, levels)) {
// Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8).
if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
if (overNested(value, levels)) {
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
}
if (serializeCanonicalJson(value, spelling) !== raw) {
+7 -9
View File
@@ -1,8 +1,9 @@
import type { AdfAttributes } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts'
import { attributeNestingFault, attributeValue, spellAttributeValue } from '../directive-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { attributeNestingMessage } from '../../adf/document.ts'
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
export type Elsewhere = { key: string; slot: 'argument' | 'content' }
@@ -22,14 +23,11 @@ export function readVocabulary(
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path)
const read = attributeValue(spelled.decoded, kind)
if (read === undefined) {
const deep = attributeNestingFault(spelled.decoded, kind, key, type)
if (deep !== undefined) return faulted(deep, path)
return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
}
const spelling = spellAttributeValue(read)
if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(key, type), path)
if (read.value === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
const spelling = spellAttributeValue(read.value)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
attrs[key] = read.value
attrs[key] = read.value.value
}
return success(attrs)
}
+4 -4
View File
@@ -3,7 +3,8 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingFault, attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { attributeNestingMessage } from '../../adf/document.ts'
import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts'
import { carryName } from '../opaque-carry.ts'
@@ -94,9 +95,8 @@ function slotText(content: readonly AdfNode[]): string | undefined {
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
const read = attributeValue(spelled.decoded, 'json')
const deep = read === undefined ? attributeNestingFault(spelled.decoded, 'json', marksAttribute, type) : undefined
if (deep !== undefined) return faulted(deep, path)
const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value)
if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, type), path)
const marks = read.value === undefined || spellAttributeValue(read.value) !== spelled.spelling ? undefined : readMarkValues(read.value.value)
if (marks === undefined) {
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path)
}
+2 -1
View File
@@ -437,7 +437,8 @@ test('names the attribute a node holds no reading for', () => {
test('names the depth an attribute value nests past, never the kind the JSON reads as', () => {
const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}`
const deeper = (key: string, type: string): string => `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels the parser carries`
const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${nested(largestNesting + 1)}"}\n:::\n`)), deeper('colwidth', 'tableCell'))
assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule'))
assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
+5 -2
View File
@@ -498,8 +498,11 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
The guard now holds each attribute value to 500 of its own and runs a second time unbounded,
which parts depth from shape, and both directions answer with `unsupported-nesting-depth`.
Depth left the shape predicates on both sides: `isJsonValue` structural and `overNested`
beside it, `adfDocumentFault` returning the code with the message and `attributeValue` the
reason it refused, so both directions answer with `unsupported-nesting-depth` naming the
attribute, and `isAdfDocument` calls a deep document a document as it always did a deep
block.
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a