36b - §8, §9 and §14's decisions move to docs/decisions.md
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled

This commit is contained in:
2026-09-28 03:54:34 +02:00
parent c64eed9301
commit fb97285899
9 changed files with 161 additions and 118 deletions
+11 -92
View File
@@ -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
+2 -1
View File
@@ -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
+124
View File
@@ -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.
+20 -19
View File
@@ -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
+1 -1
View File
@@ -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<string, string> = {
blockquote: 'blockquote',
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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())
})
-2
View File
@@ -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