1 Commits

Author SHA1 Message Date
lilleman ece1b029ee 5c: the build, the freeze audit and the release pipeline
CI / gate (push) Successful in 16s
CI / publish (push) Has been skipped
2026-09-03 20:25:41 +02:00
25 changed files with 298 additions and 79 deletions
+10
View File
@@ -7,3 +7,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
- run: bash ci.sh - 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
+17 -5
View File
@@ -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. When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way 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 "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 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 - 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 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 that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an
compile errors here rather than a consumer's crash there. The standard is the line, never 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 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 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, 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 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 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 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` `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 and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
@@ -148,7 +153,9 @@ wide `Result<T>`, 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 - `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 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. - 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 - 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 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 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. 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 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 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 `noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
+4 -2
View File
@@ -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-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-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 | | `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 | | `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 ## The guarantees
@@ -112,7 +112,9 @@ emit refuses:
carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed 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 (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 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)`, `<http://x?a=1&amp;b=2>` and `[a](/x&#10;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 - 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`. 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 - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
+5 -9
View File
@@ -1,15 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
source ./docker-images.sh
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" "$@"
}
in_image "$node_image" npm ci in_image "$node_image" npm ci
in_image "$node_image" npm run typecheck 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 "$deno_image" deno test --allow-read --no-check src/
in_image "$bun_image" bun test 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
@@ -0,0 +1 @@
unsupported-nesting-depth
+2
View File
@@ -0,0 +1,2 @@
:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
:::
+10
View File
@@ -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" "$@"
}
+11
View File
@@ -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<string> = adfToMarkdown(document)
const parsed: Result<AdfDocument, ParseError> = 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 }
+9
View File
@@ -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 })
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"target": "ES2022",
"types": []
},
"include": ["consumer.ts"]
}
+10
View File
@@ -9,10 +9,20 @@
"url": "git+https://gitea.larvit.se/larvit/adf-codec.git" "url": "git+https://gitea.larvit.se/larvit/adf-codec.git"
}, },
"type": "module", "type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"scripts": { "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\"", "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" "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
}, },
Executable
+28
View File
@@ -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"
+25 -8
View File
@@ -6,8 +6,8 @@
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
"depNameTemplate": "denoland/deno", "depNameTemplate": "denoland/deno",
"description": "Pin the Deno image ci.sh runs", "description": "Pin the Deno image the gate runs",
"managerFilePatterns": ["ci.sh"], "managerFilePatterns": ["docker-images.sh"],
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
@@ -15,17 +15,27 @@
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
"depNameTemplate": "node", "depNameTemplate": "node",
"description": "Pin the node image ci.sh runs", "description": "Pin the node image the gate runs",
"managerFilePatterns": ["ci.sh"], "managerFilePatterns": ["docker-images.sh"],
"matchStrings": ["node:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["node_image=node:(?<currentValue>[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:(?<currentValue>[0-9][^\\s\"']*)"],
"packageNameTemplate": "node",
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
{ {
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
"depNameTemplate": "oven/bun", "depNameTemplate": "oven/bun",
"description": "Pin the Bun image ci.sh runs", "description": "Pin the Bun image the gate runs",
"managerFilePatterns": ["ci.sh"], "managerFilePatterns": ["docker-images.sh"],
"matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
@@ -39,5 +49,12 @@
"versioningTemplate": "docker" "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"]
}
]
} }
+21
View File
@@ -1,12 +1,24 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { JsonValue } from '../json-value.ts'
import { adfDocumentFault, isAdfDocument } from './document.ts' import { adfDocumentFault, isAdfDocument } from './document.ts'
import { largestNesting } from '../nesting.ts'
function fault(value: unknown): string { function fault(value: unknown): string {
return adfDocumentFault(value) ?? 'accepted' 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', () => { 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({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(isAdfDocument({ 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) 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', () => { 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, '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) assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
+12 -10
View File
@@ -1,4 +1,5 @@
import { isJsonValue, type JsonValue } from '../json-value.ts' import { isJsonValue, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
export type AdfAttributes = { [key: string]: JsonValue } export type AdfAttributes = { [key: string]: JsonValue }
@@ -25,7 +26,7 @@ const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', '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)}` if (!isRecord(value)) return `an ADF document is an object: found ${describe(value)}`
const extra = extraKey(value, documentKeys) const extra = extraKey(value, documentKeys)
if (extra !== undefined) return `an ADF document holds content, type and version alone: found the key ${extra}` 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 if (!('content' in value)) return undefined
const content = value['content'] const content = value['content']
if (!Array.isArray(content)) return `an ADF document's content is an array: found ${describe(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 { 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 { 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 (!isRecord(value) || !holdsOnly(value, markKeys)) return false
if (typeof value['type'] !== 'string') 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] const pending: unknown[] = [...value]
while (pending.length > 0) { while (pending.length > 0) {
const node = pending.pop() const node = pending.pop()
if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false
if (typeof node['type'] !== 'string') return false if (typeof node['type'] !== 'string') return false
if ('attrs' in node && !isAttributes(node['attrs'])) return false if ('attrs' in node && !isAttributes(node['attrs'], levels)) return false
if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) 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 ('text' in node && typeof node['text'] !== 'string') return false
if ('content' in node) { if ('content' in node) {
const content = node['content'] const content = node['content']
@@ -81,8 +82,9 @@ function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): valu
return Array.isArray(value) && [...value].every(guard) return Array.isArray(value) && [...value].every(guard)
} }
function isAttributes(value: unknown): value is AdfAttributes { // Per value, so an attribute reaches the same 500 levels the parser reads one at (AGENTS.md §11).
return isRecord(value) && isJsonValue(value) 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
+7 -2
View File
@@ -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 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}` 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 { export function attributeValue(text: string, kind: AttributeKind): VocabularyValue | undefined {
if (kind === 'string') return { kind, value: text } if (kind === 'string') return { kind, value: text }
if (kind === 'boolean') return text === 'true' || text === 'false' ? { kind, value: text === 'true' } : undefined 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 } } } 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 { try {
const value: unknown = JSON.parse(raw) const value: unknown = JSON.parse(raw)
return isJsonValue(value) ? value : undefined return isJsonValue(value, levels) ? value : undefined
} catch { } catch {
return undefined return undefined
} }
+15 -5
View File
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.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 { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } 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') assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' } let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
assert.equal( const deeper = `unsupported-nesting-depth: an attribute value nests deeper than the ${largestNesting} levels the emitter carries`
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper)
"not-an-adf-document: an ADF document's content holds ADF nodes: one of them is not", 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', () => { test('escapes a literal delimiter that would merge with an emitted one', () => {
+3
View File
@@ -24,6 +24,9 @@ const largestListMarker = 999999999
export function adfToMarkdown(document: AdfDocument): Result<string> { export function adfToMarkdown(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document) 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 failure('not-an-adf-document', fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(document.content ?? [], 'document', [], 0) const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
+7 -3
View File
@@ -1,8 +1,8 @@
import type { AdfAttributes } from '../../adf/document.ts' import type { AdfAttributes } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts' import type { DirectiveAttributes } from '../directive-syntax.ts'
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts' import { attributeNestingFault, attributeValue, spellAttributeValue } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
export type Elsewhere = { key: string; slot: 'argument' | 'content' } export type Elsewhere = { key: string; slot: 'argument' | 'content' }
@@ -22,7 +22,11 @@ export function readVocabulary(
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined 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) if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path)
const read = attributeValue(spelled.decoded, kind) 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) const spelling = spellAttributeValue(read)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path) 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
+3 -1
View File
@@ -3,7 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.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 { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
import { carryName } from '../opaque-carry.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<AdfMark[]> { function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
const read = attributeValue(spelled.decoded, 'json') 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) const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value)
if (marks === undefined) { 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) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path)
+8 -2
View File
@@ -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(':::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('::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') 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(':::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}') 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', () => { 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('::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') assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20')
+31
View File
@@ -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<string>()
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())
})
+27
View File
@@ -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. 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 The trailing pipe of a pipe-table row is optional in input, not required; the leading one
is what every row must carry. 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.
+16 -31
View File
@@ -5,8 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
## Milestones ## 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 → Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`;
`0.3.0`. 6, 7 → `0.3.0`.
The numbering is the order the work was planned in, not the order it ships. The numbering is the order the work was planned in, not the order it ships.
- [x] **0 — Scaffold.** - [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 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 guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
nobody typed. nobody typed.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret, - [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: 3h public (§6), create the `NPM_TOKEN` secret, and open the bump PR that sets `version` to
landed the last decision `corpus/unspellable/` held and the directory went with it, so what `0.1.0` and drops `private: true`, the guard against any earlier publish. `0.1.0` is the
the code list holds from here is permanent. The parser's own code markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
additions are read here as one list before that freeze — nine sessions mint them checked-in corpus.
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.
**Settled** (the maintainer, 2026-09-01): the round-trip 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 — 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 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 `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 yet, and §8's pre-1.0 rules cover what the wider proof then finds.
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.
- [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5a — Rename to `@larvit/adf-codec`.**
- [x] **5b — The consumer's error surface.** - [x] **5b — The consumer's error surface.**
- [x] **5b1 — The error's source position.** - [x] **5b1 — The error's source position.**
- [x] **5b2 — The error messages.** - [x] **5b2 — The error messages.**
- [x] **5b3 — The code list and the flavour's gaps.** - [x] **5b3 — The code list and the flavour's gaps.**
- [x] **5b4 — The README's consumer surface.** - [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. 5c left this out because a browser
leg needs an image, a driver and a way to carry a verdict back out, none of which the gate's
`docker run` shape has today — decide the mechanism before the code.
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **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. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
+4 -1
View File
@@ -7,9 +7,12 @@
"types": [], "types": [],
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"declaration": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "outDir": "dist",
"rewriteRelativeImportExtensions": true,
"rootDir": "src",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"strict": true, "strict": true,