20 KiB
Working in this repo
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.
Decisions
In docs/decisions.md:
- 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
- The formats are API
- The code list
- Which code a cause takes
messageandpath- 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.
9. Release automation
- The bump commit renames
CHANGELOG.md's## Unreleasedto the version. - Exact versions:
save-exact=truein.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, nevernode:24), as specific as the publisher tags:oven/bun:1.4.0-alpinepins Bun's patch and leaves the base floating because Bun publishes nothing narrower. Actions pin semver tags.
10. Tests first, in Docker
Test for the behaviour wanted first, then implement until green. node --test, beside the code.
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent,
containers are torn down after a run.
The gate runs that same suite under Deno and Bun as well as Node, the three images pinned alike,
and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier,
so it holds the module graph to the fully-spelled form a browser can load; Bun runs
JavaScriptCore, the one engine of the three that is not V8, where the Unicode property escapes
emphasis matching leans on can disagree. Both refuse a run matching no test, so Node's is the only
vacuous-green guard, and a test may reach only for what all three node: shims carry — the price
of proving those engines over the corpus rather than over a smoke import.
The gate then packs the build and installs the tarball under package-tests/, so files,
exports and types are proved on the artifact that ships rather than on the source tree a
self-reference would resolve against. consumer.ts typechecks the emitted .d.ts from outside
tsconfig.build.json — declaration emit leaves the .ts specifiers
rewriteRelativeImportExtensions rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under NodeNext alone; a .d.ts reader that is not tsc stays unproven.
node-floor.js round-trips the installed package under a Node pinned to engines.node's floor.
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 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
a hang. A leg added later owes the same marker, and a function a leg reaches chains its statements
with &&, because the || that captures the leg's status suspends set -e for everything it
calls. A leg whose output is both streamed and grepped keeps the copy in a mktemp
file: tee /dev/stderr reopens fd 2, and under ./ci.sh > log 2>&1 the two offsets punch NUL
holes through each other's lines (4d).
The floors live in the test script, so npm test and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
noUncheckedIndexedAccess and ADF's optional keys force — ?? [], ?? {}, ?., an index
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, measured by oxlint since TypeScript 7 is a native compiler
publishing no in-process parser, only the unstable/ AST surface an out-of-process handshake
reaches. It is 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.
Beside the corpus, properties run over documents generated from the node tables and over generated
markdown, on a fixed seed in the gate; PROPERTY_RUNS=<runs> raises the runs and randomizes the
seed for local digging, and a counterexample found becomes a round-trip fixture. The generators and
run parameters properties share live in src/conformance/property-harness.ts, outside the build and coverage.
spec/flavour.md is read as a source too, so the node tables cannot drift from the prose they
copy: each - bullet in ## Block nodes, ## Inline nodes and ## Marks declares the nodes
named before its first em dash, with the attributes following Attributes: — a parenthesized
value set reading string — and must equal the tables in adf/. Keep prose in those sections out
of a bullet; fenced examples are skipped. It guards the attributes alone: nodes that differ in
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 (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
Style
- Two-space indent, English everywhere. Alphabetical order wherever order carries no meaning, keyed on the name a line introduces rather than where it came from: an import sorts on its first binding, type imports ahead of value imports, so moving or renaming a module reorders nothing (the maintainer, 2026-09-18).
- No casts:
as,as unknown as, non-null!. A boundary owes a type guard validating the fields it claims (isAdfDocument); past it everything is typed. Make invalid states unrepresentable. - Failures are values: everything returns
Result<T>, nothing throws.try/catchonly wrapped tightly around a call that genuinely throws, converted to a result on the spot. A reader with no path to name returnsRead<T>, and the walk attaches the path where it knows it. - Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality — a second consumer, or it goes.
Bounds
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels (
largestNesting), so a deep document is aResultrather than the stack overflow that waits near 2000. An attribute is counted from its value; a spelling that nests it deeper — the block directive'smarks, the carry — refuses in its own format, as its parser does. A list giving way to the directive form refuses at zero headroom rather than walking again; counting every list twice halved the list limit, counting the directive form once doubled the parser's frames per level (the maintainer, 2026-09-18). - Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
mark run's segments: the argument list caps near 125k and throws a
RangeErrorwhere aResultis owed. A walk pushes one at a time. A literal spread ([...value]) is not the same thing and is fine (4c). - A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its termination is the loop's own check (28).
- A reader takes the text and an index — a sticky regex whose
lastIndexthe caller sets on the line before it reads,indexOf— never a fresh slice per character, and a per-character walk hoists the scan that does not vary with the character. The pipeline persona feeds documents nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond. A scan may keep what it read for a later walk of the same text, and the fallback where it kept nothing must be the same reader over the same text at the same index, so the two cannot disagree — which is what makes the kept value a memo rather than a second spelling (4c). - The parse keeps each node's readable spelling in a memo, so the
commonMarkSpellingask stops spelling a node once per level above it (18). The node reference is the key, which holds because the parse builds one object per position;adfToMarkdownpasses no memo, where a consumer's document may hold one node at two positions (4b).textandspellingcarry no depth andheadroomis affine in it, so a read at or above the depth that filled the entry rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and an ordered list past the marker cap gives way, spending two emitter levels where the parser spent one. Only what succeeded is kept, so no path minted at another position is ever read.
Spellings
- Only the hard break's inline segment holds a raw newline — every other spelling escapes one or refuses it — which is how the whitespace carry finds a line edge.
- Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
- A readable spelling tried ahead of a general one takes the
tryprefix and fails only where the general form fails on the same node (20): refusing there refuses a document the general form spells, so a refusal the general form does not share belongs in the general form or nowhere. A readable spelling that must spell its subtree before it can give way — the list, whose thematic-break first line and blank lines exist only spelled — hands that one walk to the general form instead: giving way after the walk walks again at every level, doubling per level (4b). - The attribute vocabulary is ADF's:
adf/walks it and narrows each value to its kind, and a format spells the narrowed value. A spelling that re-checks the type is the check's second copy. Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a number is the markdown flavour's choice, not ADF's.
Layout
src/adf/holds ADF's own knowledge, imports no format, and is where a construct both formats read lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content model — and no delimiter, element name or escape reaches it. A helper that cannot answer that way is two constructs, the ADF question there and the spelling in each format, the seammarkAttributesandmarkSpellingsalready draw; one that cannot be split is a gap to ask (§15).markdown/andhtml/are peers: neither imports the other, and no third directory sits between them. A primitive knowing neither ADF nor a format stays atsrc/root. A construct rises toadf/on its second consumer, not in anticipation of one (the maintainer, 2026-09-21).- Each format directory (
markdown/,html/) parts intoemit/(ADF→format) andparse/(format→ADF), the rest of it holding what both directions read. A construct's reader lives there beside the regex the emitter escapes against, so the two cannot drift; a reader with no emit counterpart goes inparse/, unless it is part of a construct that side already holds — a grammar stays in one file rather than splitting across the seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is one function there too, never a copy per direction, however conservative the copy would be. Where the rule is the emitter's own choice, input consults it rather than restating it, and that is the only importparse/takes fromemit/—commonMarkSpellingandopeningLinkTakesDirective— so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own error rather than a second name for it. - Explicit over implicit; descriptive names; no catch-all files (
utils,helpers,misc); a file does not repeat its directory in its name —adf/document.ts, neveradf/adf-document.ts. A name is the nounspec/flavour.mdor ADF's schema uses for the thing; a directory follows a split the spec draws; a placement these rules leave open goes beside its only reader, or in what both read where there are two (the maintainer, 2026-09-18).
12. Prose to a minimum
Applies everywhere: comments, every markdown file in this repo (this one included), PR text.
- Default is no comment. One earns its single line only by naming an invariant, footgun or external constraint the code cannot show — never restatement, history, absence or arrangement. A second line belongs in the commit message or a decision entry here.
- A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant one is deletion, not trimming. A false claim in any doc is a bug, fixed where found.
- Published text — npm README, error messages, API docs — never references internal systems, tickets or repos.
13. Commits and PRs
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever.
15. The working loop
todo.md lists what is left under the release that ships it, in shipping order. One item per
session — the first under the earliest release — in the smallest PR-able chunk; split a big item
into sub-items in todo.md before starting it. A chunk running a little over or under that is not
worth deliberating; what matters is that nothing is left undone in the end. The session stops there
whatever it was asked to finish: a release is a chain of sessions, so an instruction to work until a
release is done names the chain, not the session. An open PR is a chunk already in flight, and
finishing it is the session.
Per chunk:
- Fresh worktree off updated
origin/main; implement tests-first (§10). - Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest
gate result (commit and outcome); a reviewer does not re-run
ci.shor the tests when a result exists for the commit under review, or when the diff since that result cannot affect it (docs-only) — re-run only what its own findings or fixes invalidate. - Merge the PR (standing authorization, this repo only, granted through the
0.2.0release — the maintainer, 2026-09-13), delete the item fromtodo.md— what a consumer sees of it is reworded for them intoCHANGELOG.md's## Unreleased— report, stop.
Reserved for the maintainer whatever any rule here says: changing version in package.json (a
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
Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar is very high — asking too often is the accepted cost, guessing wrong is not.
An ask is a gap in this file, and its answer is the rule that closes it, landing here — never the
instance alone. Before asking, name the class the question belongs to and the earlier
(the maintainer, …) entries of that class; where a rule already decides it, apply it without
asking, and where the rule reads two ways on this input, that reading is the ask. Never ask "A or
B?": state the gap, the earlier asks of its class, the nearest text here, a candidate rule in this
file's voice and section, and the instance it yields. A rule that keeps collecting instances is
wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the conversion serves, each given only
## Audience and the input, writing what they expect before picking among outputs the goals
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
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.mditem, always in a release, weighed against every item on that release by the personas anddocs/decisions.md§Plain markdown is a flavour of the grammar through §Names stay text — 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:
every later item weighed as above, the order written in
todo.md, and the maintainer's approval taken before any code. With work shipped toward it, it is ready to cut: report that and stop.
The continuous loop
A /loop session counts as a chain of sessions, each iteration starting by re-reading AGENTS.md
and todo.md and trusting them over anything remembered from earlier iterations. The loop session
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
up. The loop stops when only maintainer-reserved acts remain.