From d9056973a1735bd3ce596149272f60315a771505 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 20:25:41 +0200 Subject: [PATCH 1/5] 5c: the build, the freeze audit and the release pipeline --- .gitea/workflows/ci.yml | 10 +++++ AGENTS.md | 22 +++++++--- README.md | 6 ++- ci.sh | 14 +++--- corpus/errors/attribute-nesting-depth.error | 1 + corpus/errors/attribute-nesting-depth.md | 2 + docker-images.sh | 10 +++++ package-tests/consumer.ts | 11 +++++ package-tests/node-floor.js | 9 ++++ package-tests/tsconfig.json | 12 ++++++ package.json | 10 +++++ publish.sh | 28 ++++++++++++ renovate.json | 33 +++++++++++---- src/adf/document.test.ts | 21 +++++++++ src/adf/document.ts | 22 +++++----- src/markdown/directive-syntax.ts | 9 +++- src/markdown/emit/adf-to-markdown.test.ts | 20 ++++++--- src/markdown/emit/adf-to-markdown.ts | 3 ++ src/markdown/parse/directive-attributes.ts | 10 +++-- src/markdown/parse/directive-nodes.ts | 4 +- src/markdown/parse/markdown-to-adf.test.ts | 10 ++++- src/result.test.ts | 31 ++++++++++++++ todo-history.md | 27 ++++++++++++ todo.md | 47 +++++++-------------- tsconfig.build.json | 5 ++- 25 files changed, 298 insertions(+), 79 deletions(-) create mode 100644 corpus/errors/attribute-nesting-depth.error create mode 100644 corpus/errors/attribute-nesting-depth.md create mode 100644 docker-images.sh create mode 100644 package-tests/consumer.ts create mode 100644 package-tests/node-floor.js create mode 100644 package-tests/tsconfig.json create mode 100755 publish.sh create mode 100644 src/result.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0ec48c4..02ca470 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,3 +7,13 @@ jobs: steps: - uses: actions/checkout@v7.0.1 - run: bash ci.sh + + publish: + if: github.ref == 'refs/heads/main' + needs: gate + runs-on: docker-host + steps: + - uses: actions/checkout@v7.0.1 + - env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bash publish.sh diff --git a/AGENTS.md b/AGENTS.md index 55938e7..51f4f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,9 @@ less silently destroys content an editor could not represent, in a document it d When losslessness and readability conflict, losslessness wins. The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way -back yields the library's canonical spelling, and that spelling round-trips byte-identically. +back yields the library's canonical spelling, and that spelling round-trips byte-identically — +where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a +parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those. "Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks merged, JSON number semantics, an empty attrs object, marks array or content array the absent @@ -62,8 +64,8 @@ they never reach a consumer. - Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is - that gate, typechecking the shipped files alone, so `node:fs`, `process` and an ES2024 method are - compile errors here rather than a consumer's crash there. The standard is the line, never an + that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an + ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and the build are the repo's own, @@ -129,7 +131,10 @@ 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. +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. `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 @@ -148,7 +153,9 @@ wide `Result`, since half their refusals come from an emit stage that read no - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's - deliberate semver judgment. + 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. - 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 @@ -168,6 +175,11 @@ 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 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 `noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index diff --git a/README.md b/README.md index b310ced..557bd58 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ emit refuses: | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | | `unspellable-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | -| `unsupported-nesting-depth` | blocks, marks or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | +| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | ## The guarantees @@ -112,7 +112,9 @@ emit refuses: carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph; mid-text and titled images are error results. Converting back yields the - library's canonical spelling, which round-trips byte-identically. + library's canonical spelling, which round-trips byte-identically — where it converts back at + all: a parse succeeding is no promise of that, so keep the source until the way back succeeds. + `[a](/a\b)`, `` and `[a](/x y)` read cleanly and then refuse. - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding diff --git a/ci.sh b/ci.sh index 07cbae9..c9e2d52 100755 --- a/ci.sh +++ b/ci.sh @@ -1,15 +1,7 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" - -bun_image=oven/bun:1.4.0-alpine -deno_image=denoland/deno:2.9.6 -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 -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" -} +source ./docker-images.sh in_image "$node_image" npm ci in_image "$node_image" npm run typecheck @@ -26,3 +18,7 @@ fi 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" npx tsc -p package-tests +in_image "$floor_image" node package-tests/node-floor.js diff --git a/corpus/errors/attribute-nesting-depth.error b/corpus/errors/attribute-nesting-depth.error new file mode 100644 index 0000000..f54ac45 --- /dev/null +++ b/corpus/errors/attribute-nesting-depth.error @@ -0,0 +1 @@ +unsupported-nesting-depth diff --git a/corpus/errors/attribute-nesting-depth.md b/corpus/errors/attribute-nesting-depth.md new file mode 100644 index 0000000..550baf8 --- /dev/null +++ b/corpus/errors/attribute-nesting-depth.md @@ -0,0 +1,2 @@ +:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"} +::: diff --git a/docker-images.sh b/docker-images.sh new file mode 100644 index 0000000..6f25981 --- /dev/null +++ b/docker-images.sh @@ -0,0 +1,10 @@ +bun_image=oven/bun:1.4.0-alpine +deno_image=denoland/deno:2.9.6 +floor_image=node:18.20.8-alpine3.21 +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" "$@" +} diff --git a/package-tests/consumer.ts b/package-tests/consumer.ts new file mode 100644 index 0000000..9a03b94 --- /dev/null +++ b/package-tests/consumer.ts @@ -0,0 +1,11 @@ +import { adfToMarkdown, isAdfDocument, markdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec' + +const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 } + +const emitted: Result = adfToMarkdown(document) +const parsed: Result = markdownToAdf('x\n') +const guarded: boolean = isAdfDocument(document) +const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code +const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line + +export const surface = { code, guarded, line } diff --git a/package-tests/node-floor.js b/package-tests/node-floor.js new file mode 100644 index 0000000..c50919d --- /dev/null +++ b/package-tests/node-floor.js @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict' + +import { adfToMarkdown, markdownToAdf } from '@larvit/adf-codec' + +const document = { content: [{ content: [{ marks: [{ type: 'em' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 } + +const emitted = adfToMarkdown(document) +assert.ok(emitted.ok, emitted.ok ? '' : emitted.error.message) +assert.deepEqual(markdownToAdf(emitted.value), { ok: true, value: document }) diff --git a/package-tests/tsconfig.json b/package-tests/tsconfig.json new file mode 100644 index 0000000..3f19840 --- /dev/null +++ b/package-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "target": "ES2022", + "types": [] + }, + "include": ["consumer.ts"] +} diff --git a/package.json b/package.json index 75c1579..a8d1126 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,20 @@ "url": "git+https://gitea.larvit.se/larvit/adf-codec.git" }, "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "engines": { "node": ">=18" }, "scripts": { + "build": "tsc -p tsconfig.build.json", "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" }, diff --git a/publish.sh b/publish.sh new file mode 100755 index 0000000..de82485 --- /dev/null +++ b/publish.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +source ./docker-images.sh + +read_field() { + in_image "$node_image" npm pkg get "$1" | tr -d '"\r' +} + +if [ "$(read_field 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 + +: "${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' +git tag "v$version" +git push origin "v$version" diff --git a/renovate.json b/renovate.json index 10c4b43..0c04896 100644 --- a/renovate.json +++ b/renovate.json @@ -6,8 +6,8 @@ "customType": "regex", "datasourceTemplate": "docker", "depNameTemplate": "denoland/deno", - "description": "Pin the Deno image ci.sh runs", - "managerFilePatterns": ["ci.sh"], + "description": "Pin the Deno image the gate runs", + "managerFilePatterns": ["docker-images.sh"], "matchStrings": ["denoland/deno:(?[0-9][^\\s\"']*)"], "versioningTemplate": "docker" }, @@ -15,17 +15,27 @@ "customType": "regex", "datasourceTemplate": "docker", "depNameTemplate": "node", - "description": "Pin the node image ci.sh runs", - "managerFilePatterns": ["ci.sh"], - "matchStrings": ["node:(?[0-9][^\\s\"']*)"], + "description": "Pin the node image the gate runs", + "managerFilePatterns": ["docker-images.sh"], + "matchStrings": ["node_image=node:(?[0-9][^\\s\"']*)"], + "versioningTemplate": "docker" + }, + { + "customType": "regex", + "datasourceTemplate": "docker", + "depNameTemplate": "node-floor", + "description": "Pin the node image proving engines.node, held to that major", + "managerFilePatterns": ["docker-images.sh"], + "matchStrings": ["floor_image=node:(?[0-9][^\\s\"']*)"], + "packageNameTemplate": "node", "versioningTemplate": "docker" }, { "customType": "regex", "datasourceTemplate": "docker", "depNameTemplate": "oven/bun", - "description": "Pin the Bun image ci.sh runs", - "managerFilePatterns": ["ci.sh"], + "description": "Pin the Bun image the gate runs", + "managerFilePatterns": ["docker-images.sh"], "matchStrings": ["oven/bun:(?[0-9][^\\s\"']*)"], "versioningTemplate": "docker" }, @@ -39,5 +49,12 @@ "versioningTemplate": "docker" } ], - "extends": ["config:recommended"] + "extends": ["config:recommended"], + "packageRules": [ + { + "allowedVersions": "<19", + "description": "engines.node states >=18, so the image proving it stays on 18", + "matchDepNames": ["node-floor"] + } + ] } diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 149f40f..968821b 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -1,12 +1,24 @@ import assert from 'node:assert/strict' import test from 'node:test' +import type { JsonValue } from '../json-value.ts' import { adfDocumentFault, isAdfDocument } from './document.ts' +import { largestNesting } from '../nesting.ts' function fault(value: unknown): string { return adfDocumentFault(value) ?? 'accepted' } +function nested(levels: number): JsonValue { + let value: JsonValue = 1 + for (let level = 0; level < levels; level += 1) value = [value] + return value +} + +function withAttribute(value: JsonValue): unknown { + return { content: [{ attrs: { a: value }, type: 'paragraph' }], type: 'doc', version: 1 } +} + test('accepts an editor-normal document', () => { assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ type: 'doc', version: 1 }), true) @@ -44,6 +56,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) + 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) +}) + 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) diff --git a/src/adf/document.ts b/src/adf/document.ts index cfa8afe..2f544b2 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -1,4 +1,5 @@ import { isJsonValue, type JsonValue } from '../json-value.ts' +import { largestNesting } from '../nesting.ts' export type AdfAttributes = { [key: string]: JsonValue } @@ -25,7 +26,7 @@ const documentKeys = ['content', 'type', 'version'] const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] -export function adfDocumentFault(value: unknown): string | undefined { +export function adfDocumentFault(value: unknown, levels: number = largestNesting): string | undefined { if (!isRecord(value)) return `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}` @@ -37,7 +38,7 @@ export function adfDocumentFault(value: unknown): string | undefined { 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) ? undefined : "an ADF document's content holds ADF nodes: one of them is not" + return isNodeArray(content, levels) ? undefined : "an ADF document's content holds ADF nodes: one of them is not" } export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { @@ -50,23 +51,23 @@ export function isAdfDocument(value: unknown): value is AdfDocument { } export function isAdfNode(value: unknown): value is AdfNode { - return isNodeArray([value]) + return isNodeArray([value], largestNesting) } -export function isAdfMark(value: unknown): value is AdfMark { +export function isAdfMark(value: unknown, levels: number = largestNesting): 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']) + return !('attrs' in value) || isAttributes(value['attrs'], levels) } -function isNodeArray(value: readonly unknown[]): boolean { +function isNodeArray(value: readonly unknown[], levels: number): boolean { 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'])) return false - if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) 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 ('text' in node && typeof node['text'] !== 'string') return false if ('content' in node) { const content = node['content'] @@ -81,8 +82,9 @@ function isArrayOf(value: unknown, guard: (item: unknown) => item is T): valu return Array.isArray(value) && [...value].every(guard) } -function isAttributes(value: unknown): value is AdfAttributes { - return isRecord(value) && isJsonValue(value) +// 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 isRecord(value: unknown): value is Record { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index e686284..3832980 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -45,6 +45,11 @@ 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 @@ -294,10 +299,10 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } } } -function parseJson(raw: string): JsonValue | undefined { +function parseJson(raw: string, levels: number = largestNesting): JsonValue | undefined { try { const value: unknown = JSON.parse(raw) - return isJsonValue(value) ? value : undefined + return isJsonValue(value, levels) ? value : undefined } catch { return undefined } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index c50c679..edd32d1 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' +import type { JsonValue } from '../../json-value.ts' import type { Result } from '../../result.ts' -import { adfToMarkdown } from '../../index.ts' +import { adfToMarkdown, markdownToAdf } from '../../index.ts' +import { largestNesting } from '../../nesting.ts' function document(...content: AdfNode[]): AdfDocument { return { content, type: 'doc', version: 1 } @@ -334,10 +336,18 @@ 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 } - assert.equal( - markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), - "not-an-adf-document: an ADF document's content holds ADF nodes: one of them is not", - ) + 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 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.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), []) + const spelled = adfToMarkdown(document(paragraph(card(largestNesting)))) + assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) + assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(paragraph(card(largestNesting))) }) }) test('escapes a literal delimiter that would merge with an emitted one', () => { diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index d9af780..9910845 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -24,6 +24,9 @@ const largestListMarker = 999999999 export function adfToMarkdown(document: AdfDocument): Result { 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 (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) const blocks = emitBlocks(document.content ?? [], 'document', [], 0) diff --git a/src/markdown/parse/directive-attributes.ts b/src/markdown/parse/directive-attributes.ts index d3d8297..5a0db97 100644 --- a/src/markdown/parse/directive-attributes.ts +++ b/src/markdown/parse/directive-attributes.ts @@ -1,8 +1,8 @@ import type { AdfAttributes } from '../../adf/document.ts' import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts' import type { DirectiveAttributes } from '../directive-syntax.ts' -import { attributeValue, spellAttributeValue } from '../directive-syntax.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { attributeNestingFault, attributeValue, spellAttributeValue } from '../directive-syntax.ts' +import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' export type Elsewhere = { key: string; slot: 'argument' | 'content' } @@ -22,7 +22,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) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path) + 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 (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path) attrs[key] = read.value diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 9b81ede..675161b 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -3,7 +3,7 @@ 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 { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' +import { attributeNestingFault, 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,6 +94,8 @@ function slotText(content: readonly AdfNode[]): string | undefined { function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result { 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 (marks === undefined) { return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 475c372..0d23ad8 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -431,12 +431,18 @@ test('names the attribute a node holds no reading for', () => { assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') - const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}` - assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\n:::\n`)), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') }) +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` + 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') +}) + test('names the attribute value spelled outside the canonical form', () => { assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') diff --git a/src/result.test.ts b/src/result.test.ts new file mode 100644 index 0000000..f30ae80 --- /dev/null +++ b/src/result.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const sourceRoot = dirname(fileURLToPath(import.meta.url)) +const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/ +const declared = /'([a-z-]+)'/g +const callSite = /(?:failure\(|code: )'([a-z-]+)'/g + +function declaredCodes(): string[] { + const source = readFileSync(join(sourceRoot, 'result.ts'), 'utf8') + const members = union.exec(source)?.[1] + assert.notEqual(members, undefined, 'result.ts declares no ConvertErrorCode union') + return [...(members ?? '').matchAll(declared)].map(([, name]) => name ?? '').sort() +} + +function calledCodes(): string[] { + const called = new Set() + for (const name of readdirSync(sourceRoot, { encoding: 'utf8', recursive: true })) { + if (!name.endsWith('.ts') || name.endsWith('.test.ts') || name === 'result.ts') continue + for (const [, code] of readFileSync(join(sourceRoot, name), 'utf8').matchAll(callSite)) called.add(code ?? '') + } + return [...called].sort() +} + +// The list is frozen at 0.1.0 (AGENTS.md §8), so a code outliving its cause is a removal that costs a MAJOR. +test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => { + assert.deepEqual(calledCodes(), declaredCodes()) +}) diff --git a/todo-history.md b/todo-history.md index 0d46007..60274cf 100644 --- a/todo-history.md +++ b/todo-history.md @@ -482,3 +482,30 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one. The trailing pipe of a pipe-table row is optional in input, not required; the leading one is what every row must carry. +- [x] **5c — The build and the release pipeline.** Split out of 5, which kept only the + maintainer's own acts. The build: `tsconfig.build.json` gains emit of JS and `.d.ts` to + `dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so + `rewriteRelativeImportExtensions` lands beside it), plus `exports`/`files` in + `package.json`. Publish-on-version-change (§9) as `publish.sh`, run by a `main`-only job + needing the gate. The `ConvertErrorCode` freeze (§8) is checkable here: 3h landed the last + decision `corpus/unspellable/` held and the directory went with it, so what the code list + holds from here is permanent. The parser's own code additions are read here as one list + before that freeze — nine sessions mint them independently, and one cause wearing two codes + is breaking to undo after `0.1.0`. That read gets a test rather than an eye — every + `ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the + node tables — since `unspelled-block-separation` outlived its cause until 3h went looking. + All thirteen have a call site; the audit's find was the depth one 5 predicted, read wrong in + 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`. + `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 + consumer's position: declaration emit leaves the `.ts` specifiers `rewriteRelativeImportExtensions` + rewrites in the JavaScript, and nothing else in the repo reads them the way an installed + consumer would. 3k's exception list landing after the release left the README's + canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that + parse and then refuse — so it now says a parse succeeding is no promise of a way back, and + names them. diff --git a/todo.md b/todo.md index d6b6968..ec81b83 100644 --- a/todo.md +++ b/todo.md @@ -5,8 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h ## Milestones -Shipping order: 3h, 3i, 3j, 5a, 5b, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 → -`0.3.0`. +Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; +6, 7 → `0.3.0`. The numbering is the order the work was planned in, not the order it ships. - [x] **0 — Scaffold.** @@ -108,45 +108,30 @@ The numbering is the order the work was planned in, not the order it ships. cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents nobody typed. -- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret, - the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: 3h - landed the last decision `corpus/unspellable/` held and the directory went with it, so what - the code list holds from here is permanent. The parser's own code - additions are read here as one list before that freeze — nine sessions mint them - independently, and one cause wearing two codes is breaking to undo after `0.1.0` — one is - known already: a json attribute value past 500 levels reads `unsupported-node-shape` on - parse but `unsupported-nesting-depth` through the carry on emit. That read - gets a test rather than an eye — every `ConvertErrorCode` member named at a production call - site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation` - outlived its cause until 3h went looking. `0.1.0` - is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build - lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own - `allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands - beside it), plus `exports`/`files` in `package.json`. The - maintainer's bump PR also removes `private: true`, the guard against any earlier publish. - §6's browser half is first checkable here, on the emitted `dist/index.js` a browser can - load — the compile gate names no host API, and a real page converting the corpus is the - other half. Headless Firefox is that page, settling both at once: the browser proof, and the - only SpiderMonkey there is, `ci.sh`'s three legs being two V8s and a JavaScriptCore that is - not Safari's. `engines.node` gets its one-line proof here - too — `import('./dist/index.js')` under a pinned Node 18 image, which cannot run the - suite that type stripping wants 22+ for, but proves exactly what the field claims. +- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo + public (§6), create the `NPM_TOKEN` secret, and open the bump PR that sets `version` to + `0.1.0` and drops `private: true`, the guard against any earlier publish. `0.1.0` is the + markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the + checked-in corpus. **Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it — 3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry `0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed - yet, and §8's pre-1.0 rules cover what the wider proof then finds. 3k's exception list - landing after the release leaves the README's canonical-fixpoint sentence claiming more than - `0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows - that sentence or lists them. `[x](http://a\b)` is one to narrow it against: it parses - cleanly and refuses on the way back, so a successful parse does not imply a spellable - document. + yet, and §8's pre-1.0 rules cover what the wider proof then finds. - [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5b — The consumer's error surface.** - [x] **5b1 — The error's source position.** - [x] **5b2 — The error messages.** - [x] **5b3 — The code list and the flavour's gaps.** - [x] **5b4 — The README's consumer surface.** +- [x] **5c — The build and the release pipeline.** +- [ ] **5d — The browser leg (`0.1.0`).** §6's browser half is checkable on the emitted + `dist/index.js` a browser can load — the compile gate names no host API, and a real page + converting the corpus is the other half. Headless Firefox is that page, settling both at + once: the browser proof, and the only SpiderMonkey there is, the gate's three engine legs + being two V8s and a JavaScriptCore that is not Safari's. The mechanism is the decision this + item opens with: a browser leg wants an image, a driver and a way to carry a verdict back + out, none of which the gate's plain `docker run` per engine has. - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / diff --git a/tsconfig.build.json b/tsconfig.build.json index 40534a9..3f6e2c7 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,9 +7,12 @@ "types": [], "allowImportingTsExtensions": true, + "declaration": true, "erasableSyntaxOnly": true, "isolatedModules": true, - "noEmit": true, + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src", "verbatimModuleSyntax": true, "strict": true, From 0dfcc0f9ca80285e9ad21b38864b1c087393b987 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 21:19:01 +0200 Subject: [PATCH 2/5] 5c: report the depth cause from the guards, and make the publish converge --- .gitea/workflows/ci.yml | 3 + AGENTS.md | 31 ++++++--- ci.sh | 6 +- docker-images.sh => docker-runner.sh | 2 +- package-tests/package.json | 5 ++ package.json | 1 + publish.sh | 31 +++++---- renovate.json | 8 +-- src/adf/document.test.ts | 20 ++++-- src/adf/document.ts | 81 ++++++++++++++++------ src/json-value.ts | 26 ++++--- src/markdown/directive-syntax.ts | 25 +++---- src/markdown/emit/adf-to-markdown.test.ts | 7 +- src/markdown/emit/adf-to-markdown.ts | 7 +- src/markdown/opaque-carry.ts | 9 ++- src/markdown/parse/directive-attributes.ts | 16 ++--- src/markdown/parse/directive-nodes.ts | 8 +-- src/markdown/parse/markdown-to-adf.test.ts | 3 +- todo-history.md | 7 +- 19 files changed, 191 insertions(+), 105 deletions(-) rename docker-images.sh => docker-runner.sh (59%) create mode 100644 package-tests/package.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 02ca470..b57a5d5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 51f4f5f..86b9ee8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, 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 diff --git a/ci.sh b/ci.sh index c9e2d52..2708d51 100755 --- a/ci.sh +++ b/ci.sh @@ -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 diff --git a/docker-images.sh b/docker-runner.sh similarity index 59% rename from docker-images.sh rename to docker-runner.sh index 6f25981..66ed149 100644 --- a/docker-images.sh +++ b/docker-runner.sh @@ -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" "$@" } diff --git a/package-tests/package.json b/package-tests/package.json new file mode 100644 index 0000000..810279b --- /dev/null +++ b/package-tests/package.json @@ -0,0 +1,5 @@ +{ + "name": "adf-codec-package-tests", + "private": true, + "type": "module" +} diff --git a/package.json b/package.json index a8d1126..d941dc5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "files": [ "dist" ], + "sideEffects": false, "engines": { "node": ">=18" }, diff --git a/publish.sh b/publish.sh index de82485..5a3694b 100755 --- a/publish.sh +++ b/publish.sh @@ -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 + +# 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 + 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 -: "${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' -git tag "v$version" -git push origin "v$version" +if [ -z "$(git ls-remote --tags origin "v$version")" ]; then + git tag "v$version" + git push origin "v$version" +fi diff --git a/renovate.json b/renovate.json index 0c04896..1c30dd2 100644 --- a/renovate.json +++ b/renovate.json @@ -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:(?[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:(?[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:(?[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:(?[0-9][^\\s\"']*)"], "versioningTemplate": "docker" }, diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 968821b..9f2cfc9 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -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', () => { diff --git a/src/adf/document.ts b/src/adf/document.ts index 2f544b2..46960d8 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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(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 { 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' diff --git a/src/json-value.ts b/src/json-value.ts index 5c3ef83..5c7da45 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -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 +} diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 3832980..87c4976 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -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 @@ -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 } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index edd32d1..3c9df3e 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -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) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 9910845..05c7c78 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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 { 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 diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 61b1538..8fd8917 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -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 | undefine } function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result { - 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) { diff --git a/src/markdown/parse/directive-attributes.ts b/src/markdown/parse/directive-attributes.ts index 5a0db97..c268ef0 100644 --- a/src/markdown/parse/directive-attributes.ts +++ b/src/markdown/parse/directive-attributes.ts @@ -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) } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 675161b..7b4e54d 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -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 { 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) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 0d23ad8..8173638 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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') diff --git a/todo-history.md b/todo-history.md index 60274cf..68b9b53 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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 From 67df1e2f4ba2cbf48c682a0cfa871346bf31dad6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 08:53:00 +0200 Subject: [PATCH 3/5] 5c: match the mark set's nesting on both sides of the round-trip --- AGENTS.md | 6 ++-- publish.sh | 6 ++-- src/adf/document.test.ts | 10 ++++--- src/adf/document.ts | 13 +++++---- src/markdown/emit/adf-to-markdown.test.ts | 34 +++++++++++++++------- src/markdown/parse/markdown-to-adf.test.ts | 1 + todo.md | 6 +++- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86b9ee8..1b88a31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,8 +133,10 @@ shape, and threading a path to the ninth — a malformed node anywhere in the tr 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: `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: +it already is from the parser, both directions refusing the same value. A node's attribute is +counted from the value itself, never from the `attrs` object holding it; a mark's is counted three +levels in, because the block directive spells the whole mark set as one JSON attribute and the +parser reads the value at the bottom of array, mark and `attrs`. `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 diff --git a/publish.sh b/publish.sh index 5a3694b..d0f5999 100755 --- a/publish.sh +++ b/publish.sh @@ -19,9 +19,11 @@ fi name=$(read_field name) version=$(read_field version) +published=$(published_version "$name" "$version") +tagged=$(git ls-remote --tags origin "v$version") # 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 +if [ -z "$published" ]; then : "${NPM_TOKEN:?the publish needs NPM_TOKEN}" in_image "$node_image" npm ci in_image "$node_image" npm run build @@ -29,7 +31,7 @@ if [ -z "$(published_version "$name" "$version")" ]; then '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 +if [ -z "$tagged" ]; then git tag "v$version" git push origin "v$version" fi diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 9f2cfc9..b72bfc9 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -61,14 +61,16 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => { }) 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` + const deeper = (key: string, type: string, levels: number = largestNesting): string => + `the ${key} attribute of ${type} nests deeper than the ${levels} 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(fault(marked), deeper('a', 'link')) - assert.equal(isAdfDocument(marked), true) + const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }) + assert.equal(fault(marked(largestNesting - 3)), 'accepted') + assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3)) + assert.equal(isAdfDocument(marked(largestNesting - 2)), true) }) test('accepts the JSON values an attribute may hold', () => { diff --git a/src/adf/document.ts b/src/adf/document.ts index 46960d8..c4c72cf 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -23,6 +23,9 @@ export type AdfDocument = { version: number } +// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it. +const markAttributeNesting = largestNesting - 3 + const documentKeys = ['content', 'type', 'version'] const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] @@ -44,8 +47,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined { 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 attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string { + return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` } export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { @@ -101,15 +104,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined { function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined { for (const mark of marks ?? []) { - const fault = attributesFault(mark.attrs, mark.type) + const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting) if (fault !== undefined) return fault } return undefined } -function attributesFault(attrs: AdfAttributes | undefined, type: string): ConvertFault | undefined { +function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined { for (const [key, value] of Object.entries(attrs ?? {})) { - if (overNested(value)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type) } + if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) } } return undefined } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 3c9df3e..1741901 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -336,19 +336,33 @@ 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 = (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' } + const deeper = (key: string, type: string, levels: number = largestNesting): string => + `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` + const nested = (levels: number): JsonValue => { + let value: JsonValue = 1 + for (let level = 0; level < levels; level += 1) value = [value] + return value } + const card = (levels: number): AdfNode => ({ attrs: { data: nested(levels), url: 'https://example.com/a' }, type: 'inlineCard' }) + // A block directive spells the mark set as one JSON attribute, so a mark's value is read three levels in. + const marked = (levels: number): AdfNode => ({ + attrs: { panelType: 'info' }, + content: [paragraph({ text: 'x', type: 'text' })], + marks: [{ attrs: { deep: nested(levels) }, type: 'em' }], + type: 'panel', + }) + const roundTrips = (node: AdfNode): void => { + const spelled = adfToMarkdown(document(node)) + assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) + assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) }) + } + + assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3)) 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) - assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(paragraph(card(largestNesting))) }) + roundTrips(paragraph(card(largestNesting))) + assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3)) + roundTrips(marked(largestNesting - 3)) }) test('escapes a literal delimiter that would merge with an emitted one', () => { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 8173638..8e8f2d4 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -441,6 +441,7 @@ test('names the depth an attribute value nests past, never the kind the JSON rea `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(`::rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\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') }) diff --git a/todo.md b/todo.md index ec81b83..0140fa1 100644 --- a/todo.md +++ b/todo.md @@ -95,7 +95,11 @@ The numbering is the order the work was planned in, not the order it ships. fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a caller may hold one node object at two positions, where the cached depth and path are another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than - wrong until the patch. + wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads + every node and attribute value, then `nestingFault` reads them again, so the emit entry the + export persona runs in bulk walks the document twice. Both walks are linear, so this is a + constant factor rather than 4b's class change, and the parting is what gives depth its own + code (§8) — measure before joining them back. - [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** 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 From 7612feb75e1226c85dc0ae46e4629d54aafe27db Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 09:03:31 +0200 Subject: [PATCH 4/5] 5c: install the packed artifact offline so the gate needs no registry --- ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci.sh b/ci.sh index 2708d51..d389e45 100755 --- a/ci.sh +++ b/ci.sh @@ -23,6 +23,6 @@ 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' + npm install --no-audit --no-fund --no-package-lock --no-save --offline --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 From b58f51f8467b4b2809cf0b4cacd456c3919f7d8f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 09:19:09 +0200 Subject: [PATCH 5/5] 5c: keep the mark nesting explanation in one place --- src/markdown/emit/adf-to-markdown.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 1741901..3d19348 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -344,7 +344,6 @@ test('refuses marks and attributes nested deeper than the emitter carries', () = return value } const card = (levels: number): AdfNode => ({ attrs: { data: nested(levels), url: 'https://example.com/a' }, type: 'inlineCard' }) - // A block directive spells the mark set as one JSON attribute, so a mark's value is read three levels in. const marked = (levels: number): AdfNode => ({ attrs: { panelType: 'info' }, content: [paragraph({ text: 'x', type: 'text' })],