diff --git a/AGENTS.md b/AGENTS.md index 9a92350..7c0b9d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,96 +28,24 @@ In `docs/decisions.md`: - ESM only - One built entrypoint - Public on npm +- The formats are API +- The code list +- Which code a cause takes +- `message` and `path` +- Publish on a version bump +- Docs describe the release being built +- No schema validation +- No streaming APIs +- No performance budget ## 7. Nothing about any consumer No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design against the README's personas. -## 8. Semver: the formats are API - -The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing -differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR. -Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or -container is the model, not the syntax — so giving a spelled node's model content it had not, or -taking it away, is MAJOR whatever ADF's own schema does. - -The error surface is a contract too; `README.md` §The errors states it to the consumer, and the -types in `src/result.ts` hold its shape. - -### The code list - -- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose - name reads true of it in both directions; where none does and a plain name exists, a new code — - in any 0.x minor, and after 1.0 only in a MAJOR (the maintainer, 2026-09-18). -- A refusal whose cause is this library's own invariant rather than the input takes the existing - code nearest what the consumer sees — a document that does not convert is - `unsupported-node-shape` — since a code no input reaches is one no consumer can switch on (the - maintainer, 2026-09-20). -- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour - the spelling and the code goes, which the freeze is the last moment for (`unspellable-link`, the - maintainer, 2026-09-13). A cause the carry answers gets no code: a mark no spelling writes rides - the carry with its node. - -### Which code a cause takes - -- A code names the cause; where one cause recurs across node types, across one mark's attributes - or across directions, one code covers them all and `path` and `message` say which — - `unsupported-nesting-depth` is the 500-level guard whichever direction hits it, - `unspellable-character` the text node and the code block alike. Where two codes stay apart, the - line between them is what they name: `unspellable-character` is a character CommonMark rewrites - wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot - spans, in either direction. -- A claim code names the spelling claimed, never the node that spelling would have built: a - malformed `!adf:table` is a `malformed-directive`, and an alignment colon a - `malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the - colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a - claim code, key order among it, and a leaf given a body is refused at its opener, as a container - missing its closer is (the maintainer, 2026-09-16). -- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim - code — the spelling is well formed, and telling that apart from a typo is what a consumer - switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never - that code, and the two the flavour reserves part on form: a form the grammar does not have is a - claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place - is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one - type. -- A well-formed directive the node tables refuse — an attribute a node does not hold or spells - elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape - its content model does not take — is `unsupported-node-shape`, the emitter's code for the same - mismatch read the other way: one code across both directions for good, since the call site - knows which direction it called and parting them after `0.1.0` is MAJOR. -- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` - emitting — no document holds one, so no round-trip crosses them. - -### `message` and `path` - -- A message names the violation, not the rule alone — a rule by itself states a truth the reader - must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose - it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and - inline alike, `\|` for every pipe row. -- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight - branches read the document's own shape, and threading a path to the eighth — 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. - ## 9. Release automation -- `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. `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 bump commit renames `CHANGELOG.md`'s `## Unreleased` to the version. -- Docs on `main` describe the release being built rather than the version npm holds, so they match - it the moment the bump publishes; add no interim note marking the gap (the maintainer, - 2026-09-16). -- 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. - Exact versions: `save-exact=true` in `.npmrc`. - 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 @@ -316,16 +244,6 @@ Applies everywhere: comments, every markdown file in this repo (this one include One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever. -## 14. Non-goals - -No network or filesystem I/O, no name→id resolution (`docs/decisions.md` §Names stay text), no ADF -schema validation or exported validator — a refusal that keeps the round-trip is not schema -validation, so the one a spelled node carrying the same mark type twice earns stays, and input -nesting a spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS -(`docs/decisions.md` §The HTML dialect), no streaming APIs, no performance budget past §11's -scanning rule — nothing here is tuned, and no figure is promised. A CLI is a later goal (`todo.md`), -not a non-goal. - ## 15. The working loop `todo.md` lists what is left under the release that ships it, in shipping order. One item per @@ -347,7 +265,8 @@ Per chunk: reworded for them into `CHANGELOG.md`'s `## Unreleased` — report, stop. Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a -bump on `main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret. +bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the +maintainer's) and the `NPM_TOKEN` secret. ### Ask, don't guess diff --git a/README.md b/README.md index 538deea..10b174c 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,8 @@ emit refuses: - Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`. - A document nested deeper than 500 levels is an error result, not a stack overflow. -- The emitted formats are semver surface (AGENTS.md §8). +- The emitted formats are semver surface + ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)). - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides `data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup diff --git a/docs/decisions.md b/docs/decisions.md index b0c84c4..d631f78 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -182,3 +182,127 @@ an npm consumer. Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public, LICENSE in place, before the first publish. + +## The formats are API + +2026-08-23, content models 2026-09-16, the maintainer. Goals 1 and 6. Valid while consumers store +what the library emits. + +The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing +differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR. +Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or +container is the model, not the syntax — so giving a spelled node's model content it had not, or +taking it away, is MAJOR whatever ADF's own schema does. + +The error surface is a contract too; `README.md` §The errors states it to the consumer, and the +types in `src/result.ts` hold its shape. + +## The code list + +2026-08-25, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer +switches on `code` with no `default`. + +- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose + name reads true of it in both directions; where none does and a plain name exists, a new code — + in any 0.x minor, and after 1.0 only in a MAJOR (2026-09-18). +- A refusal whose cause is this library's own invariant rather than the input takes the existing + code nearest what the consumer sees — a document that does not convert is + `unsupported-node-shape` — since a code no input reaches is one no consumer can switch on + (2026-09-20). +- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour + the spelling and the code goes, which the freeze is the last moment for (`unspellable-link`, + 2026-09-13). A cause the carry answers gets no code: a mark no spelling writes rides the carry + with its node. + +## Which code a cause takes + +2026-08-28, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer +handles one cause alike whichever node, attribute or direction raised it. + +- A code names the cause; where one cause recurs across node types, across one mark's attributes + or across directions, one code covers them all and `path` and `message` say which — + `unsupported-nesting-depth` is the 500-level guard whichever direction hits it, + `unspellable-character` the text node and the code block alike. Where two codes stay apart, the + line between them is what they name: `unspellable-character` is a character CommonMark rewrites + wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot + spans, in either direction. +- A claim code names the spelling claimed, never the node that spelling would have built: a + malformed `!adf:table` is a `malformed-directive`, and an alignment colon a + `malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the + colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a + claim code, key order among it, and a leaf given a body is refused at its opener, as a container + missing its closer is (2026-09-16). +- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim + code — the spelling is well formed, and telling that apart from a typo is what a consumer + switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never + that code, and the two the flavour reserves part on form: a form the grammar does not have is a + claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place + is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one + type (2026-09-01). +- A well-formed directive the node tables refuse — an attribute a node does not hold or spells + elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape + its content model does not take — is `unsupported-node-shape`, the emitter's code for the same + mismatch read the other way: one code across both directions for good, since the call site + knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23). +- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` + emitting — no document holds one, so no round-trip crosses them (2026-09-23). + +## `message` and `path` + +2026-09-03, the path 2026-09-23, the maintainer. Goals 3 and 6. Valid while a person fixing the +input reads `message`. + +- A message names the violation, not the rule alone — a rule by itself states a truth the reader + must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose + it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and + inline alike, `\|` for every pipe row. +- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight + branches read the document's own shape, and threading a path to the eighth — a malformed node + anywhere in the tree — wants the manual stack the no-recursion rule (`AGENTS.md` §11) forces, + whose empty half no input reaches. The message names the violation instead. + +## Publish on a version bump + +2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm +token. + +`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. `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. + +## Docs describe the release being built + +2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes. + +Docs on `main` describe the release being built rather than the version npm holds, so they match it +the moment the bump publishes; add no interim note marking the gap. + +## No schema validation + +2026-08-23, the mark refusal 2026-08-25, the maintainer. Goal 1. Valid while the site a document +is saved to validates it. + +No ADF schema validation or exported validator. A refusal that keeps the round-trip is not schema +validation, so the one a spelled node carrying the same mark type twice earns stays, and input +nesting a spelling inside its own kind (`*(*a*)*`) names that mark once. + +## No streaming APIs + +2026-08-23, the maintainer. Goal 8. Valid while a document fits in memory. + +A call takes a whole document and returns a whole result. + +## No performance budget + +2026-08-23, the maintainer. Goal 8. Valid while no persona needs a speed figure. + +Nothing is tuned past the scanning rule (`AGENTS.md` §11), and no figure is promised. diff --git a/spec/flavour.md b/spec/flavour.md index f1ed888..f72f214 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -1,12 +1,12 @@ # The markdown flavour The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain -CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that -matches directive syntax below or reads as a pipe table is claimed by the flavour, and a matched -`~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a -CommonMark image fits only as its own -title-less paragraph — mid-text and titled images are named errors. The emitted form is contract -(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below. +CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches +directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair +spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark +image fits only as its own title-less paragraph — mid-text and titled images are named errors. The +emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this +grammar in the sections below. ## Canonical form @@ -76,13 +76,14 @@ normalizes to it through the round-trip. One grammar for everything CommonMark lacks, namespaced: every directive opens with the literal `!adf:`. A directive name is `[a-z][A-Za-z0-9]*` — the ADF node and mark names the sections below spell as directives. Recognition is syntactic and name-set-independent: anything matching the forms -below parses as a directive regardless of whether the name is known, and an unknown name is an -error result naming it at the opener, whatever follows it — so output an old emitter escaped stays -escaped, and erroring input gaining meaning later is MINOR, never a reparse (§8). Each name belongs -to one position, and a name the other one spells — a mark or an inline node written as a block -directive, a block node written inline — is a different error, naming the spelling it takes. Two -reserved names read back to no node: `carry` for the opaque carry, as both directive name and fence -info string, and `listBreak` for the leaf that parts two adjacent lists (Canonical form). +below parses as a directive regardless of whether the name is known, and an unknown name is an error +result naming it at the opener, whatever follows it — so output an old emitter escaped stays +escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md` +§The formats are API). Each name belongs to one position, and a name the other one spells — a mark +or an inline node written as a block directive, a block node written inline — is a different error, +naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry, +as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent +lists (Canonical form). **Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name` closes a container, and a name picks by what follows it in turn — a space or the line's end a block @@ -123,7 +124,7 @@ Which of the two a node takes is its content model, never the spelling: a model written as an opener–closer pair and one taking none as a leaf, so a leaf given a body and a container missing its closer are each a named error. A node holding no content whose model takes some is an empty pair. A spelled node's content model is contract in consequence — changing one is -MAJOR (AGENTS.md §8). +MAJOR (`docs/decisions.md` §The formats are API). Canonical spacing is the only spacing input reads: one space parts the name, `arg` and `{attrs}`, and one parts each attribute pair, with no padding inside the braces. Trailing whitespace on a @@ -184,11 +185,11 @@ HTML. ## Block nodes -The directive name is always the ADF node type. A container's body is the node's `content`; a -leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is -written; validity against ADF's content models stays the author's business (AGENTS.md §14). It -parses only in the form the emitter picks, though: a directive spelling a node the emitter would -have written as CommonMark is a named error. +The directive name is always the ADF node type. A container's body is the node's `content`; a leaf +has none. Every directive parses in any position — `markdownToAdf` builds exactly what is written; +validity against ADF's content models stays the author's business (`docs/decisions.md` §No schema +validation). It parses only in the form the emitter picks, though: a directive spelling a node the +emitter would have written as CommonMark is a named error. Each section lists attributes as `name (type)`. A parenthesized value set documents what real payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by diff --git a/src/conformance/commonmark-spec.test.ts b/src/conformance/commonmark-spec.test.ts index 6c6bb85..d9dde4c 100644 --- a/src/conformance/commonmark-spec.test.ts +++ b/src/conformance/commonmark-spec.test.ts @@ -91,7 +91,7 @@ test('the refusal list is unique per example and names real examples', () => { for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`) }) -// A mark is counted once per text node it touches (AGENTS.md §14). +// A mark is counted once per text node it touches (docs/decisions.md §No schema validation). const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] const nodeElement: Record = { blockquote: 'blockquote', diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index a72ecd4..7235b69 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -69,7 +69,7 @@ export function readInlineDirectiveNode( return success(namedNode(name, attrs.value, undefined)) } -// A name the other position spells names that spelling, never the code a later MINOR may fill (AGENTS.md §8). +// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes). function inlineSpellingFault(name: string): ConvertFault | undefined { const mark = inlineMarkSpellingFault(name) if (mark !== undefined) return mark diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 494ecc3..3017c03 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -477,7 +477,7 @@ function markType(character: string, used: number): string { return used === 2 ? 'strong' : 'em' } -// A node cannot carry one mark type twice (AGENTS.md §14). +// A node cannot carry one mark type twice (docs/decisions.md §No schema validation). function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] { return nodes.map((node) => { const marks = nodeMarks(node) diff --git a/src/result.test.ts b/src/result.test.ts index d672ad0..a85fb23 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -25,7 +25,7 @@ function calledCodes(): string[] { 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. +// The list is frozen at 0.1.0 (docs/decisions.md §The code list), 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.md b/todo.md index 305389f..778f9c8 100644 --- a/todo.md +++ b/todo.md @@ -7,8 +7,6 @@ one no goal serves is proposed as a goal and asked. Sources: `AGENTS.md`'s body, the settled text in this file's items, and `todo-history.md`, deleted with the bare `(28)` citations into it once nothing cites it. Split by `AGENTS.md` section where one chunk is too big. - - **36b — Move §8, §9 and §14's decisions.** The code list's rules, release automation and the - non-goals. - **36c — Move §10 and §11's decisions.** The engine legs, floors, size ratchet, bounds, spellings and layout; the style rules stay working rules. - **36d — Move the settled text in `todo.md`'s items and §15's dated rules, and point §15's