36a - §1–§6's decisions move to docs/decisions.md, indexed from AGENTS.md
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-28 00:37:01 +02:00
parent f855e1b348
commit becd12e294
6 changed files with 300 additions and 174 deletions
+60 -129
View File
@@ -1,106 +1,33 @@
# Working in this repo
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or
agent. Using the library: `README.md`. What is still to build: `todo.md`; a bare `(28)` cites
that item's entry in `todo-history.md`.
The rules for every collaborator, human or agent, and an index of the decisions a reader would
otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`; a bare
`(28)` cites that item's entry in `todo-history.md`.
## 1. ADF is the hub
## Decisions
README Goal 2. The lossy pair is the plain flavour: the markdown grammar's reader and writer with
the flavour set, its spellings — alerts, callouts, task markers, `==` — read and written there, so
a marker line and a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF
ahead of the writer (the maintainer, 2026-09-27; 35).
In `docs/decisions.md`:
## 2. The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
where there is a way back. CommonMark spells some things the flavour has no escape for — a
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document;
`corpus/commonmark-spec/exceptions.json` names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose.
## 3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So
does a known node no section spells where it stands: a markdown serializer spells a node by type
without checking its position, and refusing loses a document ADF itself keeps in an
`unsupportedBlock`. Where a container's own spelling cannot hold the child it has — a
`bulletList` outside `listItem`, a `codeBlock` outside text — the error result names that
instead.
- Unmappable foreign HTML element: error result naming the element — never a silent drop.
- Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
## 4. The flavour
- Directives, one grammar for everything markdown lacks, namespaced under `!adf:`: `!adf:panel info`
… `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the one escape. Not
CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and its
fence-length discipline ties a container's opener to its own body, where closing from the opener
nests by itself and leaf versus container falls out of the node's content model.
- Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
- Tables: one header row plus plain inline cells → pipe table; anything richer → directive form.
- Links: `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark
spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot
hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark
spelling would read as a link reference definition — and a directive link CommonMark could spell
is refused (the maintainer, 2026-09-13). No link wraps a link — the bracket form goes literal,
the directive form refused — which is CommonMark's prose where its reference implementation
nests one `<a>` in another (the maintainer, 2026-09-17).
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
cannot express, text always escaped. No stylesheet ships.
## 5. Dependencies
`dependencies` is empty. A runtime dependency enters only through a decision entry here stating
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
module, so entity decoding is complete without one. The CommonMark spec suite is the same shape of
data and ships vendored at `corpus/commonmark-spec/` rather than as the `commonmark-spec` dev
dependency — that package is CommonJS-only, and Renovate auto-bumping a spec version would silently
point the vendored exception list's example numbers at a renumbered suite. A spec bump is a
deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON Schemas ship
vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
`devDependencies`: `fast-check` earns its place shrinking a failing generated document to the nodes
that break it, `oxlint` measuring §10's size ratchet — TypeScript 7 is a native compiler publishing
no in-process parser, only the `unstable/` AST surface an out-of-process handshake reaches.
## 6. The package contract
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source
is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is
that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an
ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an
engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes
and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and
the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
never the higher one those repo-only tools want.
- ESM only — no CommonJS build, no dual-package hazard.
- One entrypoint: built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint —
Node refuses to type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),
so it cannot serve an npm consumer.
- Published to public npmjs as `@larvit/adf-codec`. Public source: the Gitea repo
goes public, LICENSE in place, before the first publish.
- Exact versions: `save-exact=true` in `.npmrc`.
- Plain markdown is a flavour of the grammar
- The round-trip is the product
- Markdown in is a canonical fixpoint
- Equality is editor-normal
- Unknown nodes ride the carry
- Foreign HTML is refused by name
- Names stay text
- Directives under `!adf:`
- CommonMark is a subset
- Tables
- Links
- Ids stay site-local
- The HTML dialect
- No runtime dependencies
- Standards ship as data
- fast-check
- Any ES2022 engine
- ESM only
- One built entrypoint
- Public on npm
## 7. Nothing about any consumer
@@ -191,6 +118,7 @@ types in `src/result.ts` hold its shape.
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
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
@@ -219,16 +147,16 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
over HTTP and converts the round-trip, normalization and error fixtures and the real payloads —
the `commonmark-spec` sort is the Node suite's to check — which is §6's browser half and the only
SpiderMonkey there is — the gate's other three engines are two V8s and a JavaScriptCore that is
not Safari's.
A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser.
The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
document, its error code — leaving the corpus's pairing, uniqueness, source positions and
byte-level equality to the Node suite that owns them.
over HTTP and converts the round-trip, normalization and error fixtures and the real payloads — the
`commonmark-spec` sort is the Node suite's to check — which is the browser half of
`docs/decisions.md` §Any ES2022 engine and the only SpiderMonkey there is — the gate's other three
engines are two V8s and a JavaScriptCore that is not Safari's. A WebDriver session is what carries a
verdict back out, the driver and the page's server sharing one network namespace so each is the
other's `127.0.0.1`; `--headless --screenshot` has no such channel, and loading `dist/index.js` in a
globals-stripped realm buys one by not running a browser. The leg re-checks the conversions and
nothing else — each fixture's emitted markdown, its parsed document, its error code — leaving the
corpus's pairing, uniqueness, source positions and byte-level equality to the Node suite that owns
them.
Every leg announces its name and, where a container is in play, the image, before it runs and its
elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as
@@ -244,14 +172,16 @@ functions, and a branch floor that only ever moves upward. It sits below 100 bec
compared against `undefined` — have a half no valid document reaches.
The size ratchet is the other such number, `.oxlintrc.json`'s single rule over the files
`tsconfig.build.json` builds: a per-function line ceiling, set at that set's worst and moving only
downward. It covers the built files alone, since one ceiling over the tests too would have to be
their worst, loosening the guard over the shipped code. It guards against drift and never drives a
refactor, so no cyclomatic rule and no second lint rule join it: neither measure picked out what
nine readers found hard (the comprehension panel, 2026-09-20). Three switches guard a silent green:
`IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a config gone missing
fails the leg instead of falling back to oxlint's own defaults; and `--deny-warnings`, since a rule
from a category this config never names arrives as a warning it exits 0 on.
`tsconfig.build.json` builds — `oxlint`, since TypeScript 7 is a native compiler publishing no
in-process parser, only the `unstable/` AST surface an out-of-process handshake reaches: a
per-function line ceiling, set at that set's worst and moving only downward. It covers the built
files alone, since one ceiling over the tests too would have to be their worst, loosening the guard
over the shipped code. It guards against drift and never drives a refactor, so no cyclomatic rule
and no second lint rule join it: neither measure picked out what nine readers found hard (the
comprehension panel, 2026-09-20). Three switches guard a silent green: `IIFEs: true`, since oxlint
exempts an IIFE otherwise; an explicit `-c`, so a config gone missing fails the leg instead of
falling back to oxlint's own defaults; and `--deny-warnings`, since a rule from a category this
config never names arrives as a warning it exits 0 on.
The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's
editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
@@ -269,11 +199,11 @@ of a bullet; fenced examples are skipped. It guards the attributes alone: nodes
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
both answer to the round-trip corpus and to nothing else where a node has no fixture.
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
documentation, since any value round-trips. What the schema holds and the tables do not spell is
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
someone spells it or pins it.
The tables answer to Atlassian's schema too (`docs/decisions.md` §Standards ship as data): for every
node and mark they spell, the attribute names and kinds equal what `full.json` and `stage-0.json`
hold between them. Value sets stay documentation, since any value round-trips. What the schema holds
and the tables do not spell is pinned by name — an attribute as a gap, a type as carried — so a
re-pin adding either goes red until someone spells it or pins it.
## 11. Code rules
@@ -388,12 +318,13 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
## 14. Non-goals
No network or filesystem I/O, no name→id resolution (§3), 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 (§4), 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.
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
@@ -441,8 +372,8 @@ The verdict lands in the item it settles (the maintainer, 2026-09-25).
### Rules the loop has settled (the maintainer, 2026-09-18)
- A finding inside the chunk's item is fixed in the chunk. Outside it, a new `todo.md` item, always
in a release, weighed against every item on that release by the personas and §1–§3 — an item it
outweighs moves later. A weighing no rule decides is asked as a gap.
in a release, weighed against every item on that release by the personas and Goals 1 and 2 — an
item it outweighs moves later. A weighing no rule decides is asked as a gap.
- A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks,
naming the number it can reach. A number the code needs and no rule states is a gap.
- An earliest release with no items left and nothing shipped toward it is planned as the chunk: