Skip to content

Markdown Projection ​

The convention for deterministically deriving human- and AI-readable Markdown from the IR (aburi.ir.v1) and the Diff (aburi.diff.v1).

See:


1. Purpose ​

The L3 IR is JSON that prioritizes machine readability above all. It is rendered as Markdown to serve three human + AI use cases: "viewed in a PR comment", "read on first contact with a repository", and "querying a single symbol via aburi explain".

The projection is deterministic: the same IR always produces the same Markdown.

2. Output file layout ​

out/
├─ workspace.md                       # L0 monorepo overview
├─ components/
│  ├─ billing.md                      # L1 + L2 (all symbols of the billing component)
│  ├─ pricing.md                      # L1 + L2
│  └─ shared.md                       # L1 + L2
├─ diff.md                            # output of aburi diff
├─ diff.full.md                       # aburi diff --max-bytes, only when the cap shortened diff.md
└─ symbols/                           # generated individually by aburi explain (on demand)
   └─ ts-apps-billing-src-InvoiceService-ts-InvoiceService-createInvoice.md
  • workspace.md: always a single file
  • components/<id>.md: one file per component (L1 + L2 combined)
  • diff.md: generated only when aburi diff runs
  • diff.full.md: the same diff with no size cap, written only when --max-bytes changed diff.md and removed by every other aburi diff run (§6.4)
  • symbols/: generated only when aburi explain runs

2.1 Why L2 is combined per component ​

One file per symbol would explode the file count (hundreds to thousands) and scatter git diffs. L1 (architecture) and L2 (symbol details) are combined within a component: 1 component = 1 file.

If a huge component (>500 symbols) becomes hard to read, a future split into components/<id>/symbols.md will be considered, but currently the combined form is kept.

3. Common format conventions ​

3.1 Markdown dialect ​

  • CommonMark + GitHub-Flavored Markdown (tables / <details> / fenced code blocks)
  • mermaid diagrams use ```mermaid fences (GitHub native rendering)
  • HTML tags are limited to <details> / <summary> / <sub> (kept minimal for CommonMark compatibility)

3.2 Ordering conventions (fixed for diff stability) ​

TargetOrder
Componentascending by id
Files (within a component)ascending by path
Symbol (within a file)ascending by source.startLine (ties broken by id ascending)
Decorator (display)ascending by line
Rule / Effect / Callascending by line (= source order)
Dependencylexicographic by (from, to, via)
Diff symbol entries (within each section)ascending by id

The ordering conventions match the JSON side (ir-schema §1). If the projection changed the order, diffs would become meaningless.

3.3 Path display ​

  • Always POSIX (forward slashes)
  • Relative to the workspace root
  • Wrapped in backticks: `apps/billing/src/InvoiceService.ts`

3.4 Code fragment display ​

LengthDisplay
≤ 80 charsinline backticks: `customer.creditLimit < invoice.total`
> 80 chars or multilinefenced code block (no language hint)
Original canonical string exceeds 120 charsalready truncated with a trailing ... at the IR stage (ir-schema.md §8.2), so used as-is

Both forms size their fence to the value rather than assuming one. A code span opens and closes with one more backtick than the longest run inside it. A fenced block opens with three backticks, or one more than the longest run in the source where that is longer. Everything a projection puts in a code span goes through this rule: key === `x-${plugin}:write` is ordinary TypeScript, and under a fixed single backtick it would close the span at its own first backtick and spill the rest of the row into the document as Markdown.

A span whose value opens or closes with a backtick also carries a space of padding at each end, for two separate reasons worth keeping apart:

  • It is needed. CommonMark reads the delimiter as a run of backticks, so a value beginning with one extends that run: two backticks of delimiter against a value starting with a backtick is a three-run against a three-run, and the delimiters are consumed instead of delimiting. The space is what separates the value from the run — the spec says as much.
  • It is safe. The parser removes one space from each end again, but only when the content both begins and ends with a space and is not made entirely of spaces. Hence padding on both ends or neither. The one value this cannot round-trip is an all-space one: a single space renders as three, because the stripping rule declines to touch it.

Newlines collapse to a single space inside a code span, because a span belongs to one row. A value that needs its lines is a fenced block instead — and a fenced block cannot sit mid-row: it ends the list item it was written into unless it is indented to the item's content column (§5.6).

A value with nothing in it gets no span at all — `` is two literal backticks, not an empty code span — so it renders as (empty). Emitting nothing is the failure this replaces: it is indistinguishable from an absent field, and one level up it deletes the row that was about to report the change.

3.4.1 Table cells ​

GFM resolves table cells before it parses inlines, so a | in a value opens a column the header row never declared and every cell after it shifts left — inside a code span too, where a reader would expect the pipe to be literal. Every cell this projection emits therefore escapes | as \|, which the row scanner reads as a literal pipe, and maps newlines to <br>.

The scanner reads a backslash and the character after it as one escape pair, so a value that already contains \| needs its backslash doubled first: escaped naively it becomes \\|, where the first backslash consumes the second and the pipe delimits again. Only a backslash run directly in front of a pipe is doubled, so a path elsewhere in the cell keeps its backslashes. The cost is one visible backslash inside the code span, which is the price of a column that still lines up; GFM has no encoding that avoids both.

Which cells can actually carry one is narrower than it looks: ComponentId is kebab-case, EffectId is a frozen enum or an x-… pattern, and RelativePath and SymbolId forbid backslash outright. RelativePath admits |, and Call.target (minLength: 1, nothing else) admits both. The escaping is applied to every cell regardless — a table whose correctness depends on which column the value landed in is one schema change away from being wrong.

3.5 Confidence badges ​

ValueDisplay
highno badge (default)
medium⚠ medium
low⚠ low

Low-confidence symbols/effects explicitly signal "the machine is not confident" to reviewers. An effect carries the badge at the end of its row (§5.7). A Symbol carries it after its name and kind on every heading that names it — the component page (§5.2), every diff.md entry that renders the Symbol under its own heading (§6.2), the explain title (§7), dropped or not — and on the names-only row that stands in for such a heading when the size cap shortens a section (§6.4), so no Symbol a view gives its own heading or row loses the badge. Rows that list a Symbol among other facts (Moved, Dropped changes, the Slice View members) do not carry it.

3.6 dropped display ​

Dropped symbols are shown in a ## Dropped section inside a <details> fold:

md
## Dropped

<details>
<summary>14 dropped symbols</summary>

- `ts:apps/billing/src/dto/create-invoice.dto.ts#CreateInvoiceDto` — pure DTO
- `ts:apps/billing/src/types.ts#Invoice` — interface (data model)

</details>

4. L0 — workspace.md ​

The monorepo overview.

4.1 Structure ​

md
# Workspace: <project name>

**Languages**: ts, py
**Managers**: pnpm (`apps/*`, `packages/*`), uv (`services/*`)
**Symbols**: 542 kept · 87 dropped (across 1234 files)
**Generated**: aburi 1.0.0 at 2026-06-21T15:30:00Z

## Components

| id | roots | languages | frameworks | symbols |
|---|---|---|---|---|
| billing | `apps/billing`, `packages/billing-domain` | ts | nestjs | 89 |
| docs    | `docs`                                    | ts | —      | 4  |
| pricing | `packages/pricing` | ts | — | 42 |
| shared  | `packages/shared`  | ts | — | 31 |

## Component dependencies

```mermaid
graph LR
  billing["Billing"]
  docs["Docs"]
  pricing["Pricing"]
  shared["Shared"]
  billing --> pricing
  billing --> shared
  pricing --> shared
```

Fallback list:

- billing → pricing (via `import`)
- billing → shared (via `import`)
- pricing → shared (via `import`)

## Files not analysed

3 of 1234 file(s) produced no Symbols.

- **over-size** (2):
  - `apps/web/public/bundle.js`
  - `packages/gen/schema.ts`
- **parse-failed** (1):
  - `apps/web/src/route.ts`

## Effect surface (top 10 by count)

| effect | count | components |
|---|---|---|
| db.read | 41 | billing, pricing |
| db.write | 23 | billing |
| network.http | 8 | shared |
| event.publish | 5 | billing |
...

Files not analysed is emitted only when stats.skippedFiles is non-empty (ir-schema.md §2), grouped by reason because the shape — one file, or all of them — is the thing to notice before the paths are. A document that predates the field omits the section rather than rendering it empty: "this run lost nothing" and "this writer could not say" are different answers, and the Symbols header line above distinguishes them by reporting parsedFiles beside totalFiles whenever the two differ.

4.2 mermaid ​

Every component declared in ir.components renders as a mermaid node — even isolated ones with no incident dependencies — so the L0 view matches the "full monorepo view" contract of overview.md §3.1 (docs above is such a node). Node declarations are ordered ascending by id; edges are ordered lexicographically by (from, to, via). Component ids are ASCII kebab-case per ir-schema.md §4, sanitized to snake_case for mermaid (billing-api → billing_api) — the mapping is injective because ComponentId cannot contain _. Labels use Component.name with mermaid-hostile characters (", <, >, ], newline) escaped so the id["label"] syntax stays intact.

If the union of declared components and edge endpoints exceeds 100 nodes, the mermaid block is replaced by an explicit _Component graph omitted…_ note so a missing diagram is not mistaken for a broken renderer, and only the text bullet list of edges is emitted (avoids unreadability). Turning mermaid output off entirely via config (e.g. output.mermaid: false) is planned; the config-schema decision is pending.

4.3 generation metadata ​

Displayed only when generatedAt is present in the IR. Omitted under --no-timestamp.

5. L1 + L2 — components/<id>.md ​

The component's logical boundary + full details of every symbol belonging to that component.

5.1 Structure ​

md
# Component: billing

**Name**: Billing
**Roots**: `apps/billing`, `packages/billing-domain`
**Languages**: ts
**Frameworks**: nestjs
**Symbols**: 89 kept · 14 dropped

## Public API

- `apps/billing/src/routes/**`
- `ts:packages/billing-domain/src/index.ts#Invoice`

## Dependencies

- billing → pricing (via import)

## Symbols

(symbol rendering per §5.2 follows)

## Dropped

(§3.6)

5.2 Symbol display ​

Grouped by file; within a file, ascending by source.startLine:

md
### `apps/billing/src/InvoiceService.ts`

#### `InvoiceService.createInvoice` *(method)*
**Boundary**: `@Post('/invoices')` `@UseGuards(AuthGuard)`
**Signature**: `(customerId: CustomerId, items: LineItem[]) → Promise<Invoice>` throws `CreditLimitExceeded` ⚡async
**Rules**:
- guard: `customer.creditLimit < invoice.total` (L58)
- throw: `new CreditLimitExceeded(customer.id, invoice.total)` (L60)
- return: `{ ...invoice, status: 'created' }` (L80)

**Effects**:
- db.write: `prisma.invoice.create` (L75)
- event.publish: `eventBus.emit` (L78) ⚠ medium

**Calls**:
- `pricing.calculateTotal` (L70)

<sub>api=`9ee77913af43` logic=`7ecf8c1cebe7` syntax=`a3f2e1d0c9b8`</sub>

A Symbol whose confidence is not high carries the badge (§3.5) at the end of its heading, as #### \XController` (class) ⚠ medium`.

A blank line follows each list section that something comes after. Without it, the next label or the <sub> line is a lazy continuation of the last bullet's paragraph and renders inside that bullet, except after a fenced rule row, which a paragraph cannot continue (MP14).

5.3 Section omission conventions ​

If a symbol's field is empty, the corresponding section is not emitted:

  • decorators[] empty → omit the Boundary / Decorators line
  • signature: null → omit the Signature line
  • rules[] empty → omit the Rules section
  • effects[] empty → omit the Effects section
  • calls[] empty → omit the Calls section

A symbol with everything empty (class without boundary, no methods) is normally dropped, but a module class that carries only a boundary decorator, for example, shows the Boundary line only.

5.4 Decorator display ​

KindRendered line
boundary=true only**Boundary**: \@A` `@B``
boundary=false only**Decorators**: \@A` `@B``
mixedrender both

5.5 Signature display ​

(name: type, name: type) → output form. Multiple outputs are separated by |. throws: A, B is appended. async / generator* / <T,U> (type parameters) are shown alongside as badges.

Example:

(id: string) → Promise<User | null> throws NotFoundError ⚡async

5.6 Rule display ​

typeDisplay
guard- guard: `<condition>` (L<line>)
throw- throw: `<what>` (L<line>)
return- return: `<expr>` (L<line>)
loop- loop (`<loopKind>`) (L<line>)
try- try (L<line>)
switch- switch: `<condition>` (L<line>)
match- match: `<condition>` (L<line>)

A payload that §3.4 sends to a fenced block — over 80 characters, or multiline — takes a second row shape, because the compact one cannot hold a block. The line tag moves ahead of the colon so the fence is last, and the fence is indented two spaces into the list item:

md
**Rules**:
- guard (L3):
  ```
  user.role === 'admin' && flags.enabled && !session.expired && ctx.tenant === wantedTenant
  ```
- loop (`for`) (L9)

At column 0 the fence would end the list instead: (L3) would become a paragraph of its own and the rules below it would restart as a second list. Boolean conditions above the threshold are routine — ir-schema.md §8.2 truncates a payload only past 120 characters — so this is the shape a reviewer sees whenever a guard is long, not an edge case.

Length is what reaches this shape in practice. §8.2 also has the extractor remove newlines from condition / what / expr, so a multiline payload means a writer that did not; it renders as the same fenced block rather than breaking the row.

A payload that is present but empty renders as - guard: (empty) (L3). The schema puts a maxLength on these three fields and no minLength, so the empty string is a document to render rather than an invariant to refuse — unlike dropReason, whose minLength: 1 is what lets the projection throw on it.

5.7 Effect display ​

- <effect.id>: `<target>` (L<line>) [<plugin>]<confidence-badge>

Examples:

- db.write: `prisma.invoice.create` (L75) [effects-prisma]
- event.publish: `eventBus.emit` (L78) [effects-nest] ⚠ medium

Extension effects with the x- prefix use the same form:

- x-stripe:charge: `stripe.charges.create` (L42) [effects-stripe]

5.8 Call display ​

- `<target>` (L<line>)

When resolved is non-null this may be elided (a future release will consider internal links to the symbol id — see the roadmap):

- `pricing.calculateTotal` (L70) → [`pricing.calculateTotal`](#pricing-calculatetotal)

5.9 Fingerprint display ​

The 3 axes on one line via <sub>:

<sub>api=`9ee77913af43` logic=`7ecf8c1cebe7` syntax=`a3f2e1d0c9b8`</sub>

Symbols whose fingerprint is all zeros (dropped) do not emit a fingerprint line.

6. Diff Markdown — out/diff.md ​

The output of aburi diff. Its primary use case is pasting into PR comments.

6.1 Overall structure ​

md
# Aburi diff: <base.ref>..<head.ref>

**Summary**: +5 added · -3 removed · ~12 changed · 2 moved · 1 moved+changed · ?2 unknown

## ⚠ API changes
## 🔧 Logic changes
## 🧵 Slice View
## ➕ Added
## ➖ Removed
## ❔ Unknown
## 🚫 Not compared
## 🔀 Moved + Changed
## 🔀 Moved
## 🧱 Component changes
## 🔗 Dependency changes
## 💧 Dropped changes
## 🎚 Confidence changes
## 🎨 Syntax-only changes

The section order is fixed, highest importance → lowest, and this list is the order: §6.4 drops sections from the bottom of it, and MP13 is stated against it, so a list that has drifted from the projection is a size cap that drops the wrong thing.

Three sections — Moved, Dropped changes and Syntax-only changes — are folded in <details>. They are not contiguous: Component changes and Dependency changes sit between Moved and Dropped changes, unfolded, and Confidence changes between Dropped changes and Syntax-only changes. Moved + Changed is not folded either, because it carries semantic change worth reading.

The · ?N unknown suffix on the Summary line appears only when summary.unknown is non-zero, so the line a reviewer skims on every PR does not carry a permanent ?0. It qualifies the counts beside it: added and removed are both smaller than the truth by that much.

6.2 Display form of each section ​

⚠ API changes ​

Entries with status: "changed" or "moved+changed" and delta.apiChanged: true.

md
### `InvoiceService.createInvoice` *(method)*
**File**: `apps/billing/src/InvoiceService.ts:42`

- signature.outputs: `Promise<Invoice>` → `Promise<InvoiceWithReceipt>`
- signature.throws added: `NotFoundError`
- decorator added: `@UseGuards(AuthGuard)`
- decorator removed: `@UseGuards(LegacyGuard)`
- decorator modified: `@tsed.Post`

added and removed print the decorator's raw, arguments and receiver included, as the non-delta list does (§5.4). modified prints qualifier.name: the arguments are dropped because they may be the change, and the receiver is kept because it may be. Both fall back to qualifier.name when raw is absent. A modified row shows the head side only, so a receiver lost (@nest.Post → @Post) reads @Post, the same as an argument edit.

A change whose delta.confidenceChanged is also true adds a row such as - confidence: `high` → `medium` , base side first, in whichever section the entry lands. This holds for every section that renders a delta body, Moved + Changed included. A fingerprint flag with no field-level row to explain it gets - <axis> fingerprint changed; no field-level detail was recorded; the component and confidence rows do not count as that explanation, since no fingerprint reads either, so a syntax change moved into Confidence changes still says it happened.

🔧 Logic changes ​

Entries with delta.logicChanged: true.

md
### `RolesGuard.canActivate` *(method)*
**File**: `apps/billing/src/guards/roles.guard.ts:9`

- effects added:
  - db.write: `prisma.audit.create` (L75)
- rules added:
  - guard: `!user.verified` (L42)
- rules removed:
  - guard: `roles.length === 0` (L40)

➕ Added / ➖ Removed ​

Full symbol rendering (same as §5.2):

md
### `InvoiceService.refundInvoice` *(method)*
**File**: `apps/billing/src/InvoiceService.ts:101`
**Boundary**: `@Post('/refund')`
**Rules**:
- guard: `!invoice.canRefund` (L110)
- throw: `new RefundNotAllowed()` (L111)

**Effects**:
- db.write: `prisma.invoice.update` (L120)

❔ Unknown ​

Entries with status: "unknown" — a Symbol one document has and the other never analysed the file for (diff-algorithm.md §3.5.1). Rendered directly after Removed, because a reader who scrolled to Removed needs to see what is missing from it; rendered apart from it because the next action is different — an entry here is not a change to review but a gap to close.

md
### `handleRequest` *(function)*
**File**: `apps/web/src/route.ts:12`
**Why**: the head scan skipped `apps/web/src/route.ts` (parse-failed), so this Symbol may still exist

absentFrom: "base" reads may not be new instead. The reason is quoted because it decides the next move: parse-timeout usually clears on a re-run, the rest clear only when the file is fixed.

🚫 Not compared ​

notCompared[] — files neither revision analysed (diff-algorithm.md §6.3), so nothing above says anything about them. Beside Unknown, and apart from it for the reason Unknown is itself apart from Removed: the next action differs. Both are gaps rather than changes, so a reader scanning for what the diff does not cover finds them together; what separates them is who can close one — an Unknown Symbol needs one revision re-scanned, while a file here was missed by both, and is usually a standing property of the workspace that every diff will keep missing until the cause is changed.

md
- `apps/web/src/route.ts` — parse-timeout at base, over-size at head
- `vendor/bundle.js` — over-size on both

Both reasons, and only collapsed to one phrase when they agree. They can differ, and the pair is what says whether a re-run is enough.

The section is omitted when the array is empty, and equally when the key is absent — a diff written before the field existed cannot say what it missed, and rendering the section over an assumed empty list would report "nothing was missed" on every archived document.

🔀 Moved + Changed ​

md
### `formatMoney` *(function)*
**Moved**: `apps/billing/src/util.ts` → `packages/billing-domain/src/util.ts` (`git-rename`)
**Delta**:
- effects added:
  - state.mutate: `result.value += ...` (L12)

A Symbol can move without leaving its file: the diff counts any change of id in a matched pair as a move (diff-algorithm.md §4), so a rename or a new owner within one file is one too. Its two paths would read the same twice, so the line names the file once and gives the name it had and the name it has, each with its line:

md
### `UserRepo.save` *(method)*
**Moved**: within `apps/billing/src/repo.ts`: `Repo.save` (L12) → `UserRepo.save` (L30) (`name-signature`)
**Delta**:
- rules modified:
  - guard: `user.id !== null` (L31)

The folded list below reads the same way, less the head name it otherwise opens with, since the route already gives it. A Slice member's follow-up line opens with the route, ↳ moved: within `apps/billing/src/repo.ts`: `Repo.save` (L12) → `UserRepo.save` (L30); delta.logicChanged, as a move between files opens with its two paths. A names-only row ends (from `Repo.save` at L12) where a move between files ends (from `<path>`).

🔀 Moved (folded) ​

md
<details>
<summary>2 moved (no semantic change)</summary>

- `formatMoney`: `apps/billing/src/util.ts` → `packages/billing-domain/src/util.ts` (`git-rename`)
- `parseAmount`: `apps/billing/src/util.ts` → `packages/billing-domain/src/util.ts` (`git-rename`)

</details>

🧱 Component changes ​

md
### Added
- `payments` — roots: `apps/payments`

### Removed
- `legacy-auth`

### Changed
- `billing`: name (`Billing` → `Billing & Invoicing`)
- `payments`: description (none → `Settlement and payouts`)
- `catalog`: roots, publicApi, frameworks

The fields listed for a changed Component are read from the entry's before / after, not from its delta. The delta summarises three axes (roots, publicApi, frameworks); a rename, a new language, or an edited description moves none of them (diff-algorithm.md §6.1). Entries with all three booleans false did not exist until §6.1 was fixed, and a renderer reading only the booleans would draw them as a row whose colon is followed by nothing — so both halves moved together.

Scalar fields — name, description — carry their before → after inline, because for them that is the whole change; the list-valued fields name themselves and leave the values to the artifact. An absent description renders as none, which is the same answer as null (ir-schema.md §1.1); one that is present and empty renders as (empty), which is a different answer.

Both scalars are free-form text out of the config file, and this row reaches a pull request comment body, so they render through a code span the value cannot break out of: newlines collapse to a space, and the fence widens past the longest backtick run inside the value (§3.4). A component whose only difference is in a field this version of the projection has no rendering for — an IR written by a newer Aburi — is named alone, on the same row shape without the colon.

🔗 Dependency changes ​

md
### Added
- `billing` → `payments` (via `import`)

### Removed
- `billing` → `legacy-auth` (via `import`)

### Unknown — the other revision never read one end
- `ts:src/route.ts#handleRequest` → `ts:src/log.ts#log` (via `call`) — the head scan skipped `src/route.ts` (parse-failed)

The Unknown group is not split by level the way the added and removed groups are. Only a Symbol endpoint has a file to lose (diff-algorithm.md §6.2.1), so every entry in it is a symbol-level edge by construction. Each line names the file and the reason after the edge, because that is what decides the reviewer's next move — parse-timeout says re-run, parse-failed and extraction-failed say fix something.

💧 Dropped changes (folded) ​

md
<details>
<summary>4 added / 1 removed</summary>

### Added
- `ts:apps/billing/src/dto/refund.dto.ts#RefundDto` — pure DTO

### Removed
- `ts:apps/billing/src/dto/legacy.dto.ts#LegacyDto` — pure DTO

</details>

🎚 Confidence changes ​

delta.confidenceChanged: true and apiChanged: false and logicChanged: false. The fingerprints do not read confidence, so the same code classified less surely has no other section to land in (diff-algorithm.md §4). It ranks above Syntax-only, which also takes a change with syntaxChanged set, because that section is one folded line per entry and the before and after would not show. Unfolded, with the same entry shape as API changes:

md
### `XController` *(class)* ⚠ medium
**File**: `apps/billing/src/x.controller.ts:12`

- confidence: `high` → `medium`

🎨 Syntax-only changes (folded) ​

delta.syntaxChanged: true and apiChanged: false and logicChanged: false and confidenceChanged not true:

md
<details>
<summary>3 symbols (implementation refactor only, no semantic change)</summary>

- `InvoiceService.findAll` (`apps/billing/src/InvoiceService.ts:88`)
- ...

</details>

6.3 One-line summary (CLI stdout) ​

When aburi diff runs, a one-line summary is printed to stdout:

+5 -3 ~12 ↔2 ⤴1   (added / removed / changed / moved / moved+changed)

It points to out/diff.md for the details.

6.4 Size cap (maxBytes) ​

The document's primary destination has a hard ceiling: GitHub rejects an issue-comment body over 65536 bytes with a 422, and posts nothing. A minimal symbol renders at roughly 210 bytes, so a pull request adding about 310 symbols is already past it — a large refactor is exactly the review that loses its report.

projectDiff(diff, { maxBytes }) caps the result. Absent, there is no cap: a file on disk should hold everything, and diff.json always does.

The cap is honoured section by section, never by cutting the string. A cut at 65536 bytes lands inside a <details> block or a code fence as often as not, and what GitHub then renders is an open fence swallowing the rest of the report. Sections give way in ascending order of importance, which is the §6.1 order read from the bottom: Syntax-only first, API changes last.

A section can give way in two steps. The sections whose entries are whole Symbols — API changes, Logic changes, Added, Removed, Unknown, Moved + Changed, Confidence changes — have a names-only form: the same heading, a line saying the entries are short, and one row per Symbol,

md
- `handleInvoice` *(function)* — `src/billing/invoice.ts:42`

(the confidence badge follows the kind as on the heading; Unknown adds the side and the skip reason, Moved + Changed the file it moved from, Confidence changes the two values, (`high` → `medium`)). The row above is 62 bytes, so a few hundred of them take 18–25 KB, where the full entries of one large section can take forty. The other sections are already lists, or are views (Slice View, Component and Dependency changes) with nothing shorter to say, and have only their full form. A section is offered its names-only form only where that form, with its line saying what it is, is smaller than the section: one or two thin entries can be longer as a list, and shortening them would grow the document.

A document that fits whole is emitted as it is. Otherwise the form of each section is decided in two passes, each walking the §6.1 order from the top and keeping a change only if the whole document, note included, still fits:

  1. Which sections stay. Each section is kept in its smallest form — names-only where it has one, full where it has not — if it fits beside the sections already kept, and omitted if it does not.
  2. How much of them. The names-only sections are made full again from the top, until one does not fit.

So a section goes only when it cannot fit, at its smallest, beside every more important section at theirs, and a reviewer never loses an API change for an implementation refactor to stay. A section too large to fit even as names no longer takes the smaller ones below it with it: they stay, and the note names it as omitted. Among the sections with a names-only form, the full ones are the first ones, and once one is short every one below it is short or gone. The headings that survive are always in §6.1 order. The title and the Summary line are never dropped, so a budget smaller than those is not achievable and the document comes back over it.

This matters most for the largest diffs. Dropping whole sections alone left a diff of 302 removed Symbols with API changes as its only section, and named none of the 302, although ➖ Removed is what --fail-on removed gates on: the red check and the section that explains it went together. Under the two passes, every one of them is named (MP13a).

A capped document says so, directly under the Summary, telling the short sections apart from the ones that went:

md
> ⚠ **3 sections were omitted** to keep this report within 65507 bytes: 🔗 Dependency changes, 💧 Dropped changes, 🎨 Syntax-only changes. The full report is the same diff rendered without a size cap.
md
> ⚠ **5 sections list names only** and **3 sections were omitted** to keep this report within 65507 bytes. Names only: ⚠ API changes, 🔧 Logic changes, ➕ Added, ➖ Removed, 🔀 Moved + Changed. Omitted: 🧵 Slice View, 🔗 Dependency changes, 🎨 Syntax-only changes. The full report is the same diff rendered without a size cap.

The last sentence is the pointer to what the cap removed. projectDiff cannot know where that is, so without help it says only that the full report is the same diff rendered without a cap; a caller that wrote one passes fullReportLocation, a Markdown noun phrase, and the sentence names it. The phrase sits inside the one-line note, so a line break in it is refused (RangeError), and it counts against maxBytes like the rest of the note. aburi diff --max-bytes writes the uncapped document as diff.full.md whenever the cap changed anything, before diff.md so the note never points at a file that failed to land, and its note reads The full report, the same diff without a size cap, is `diff.full.md` beside `diff.md`.

On the one path that comes back over budget — every section dropped and the remainder still too large — the note says that instead, because a line promising a budget the bytes below it miss is worst exactly where it matters most:

md
> ⚠ **4 sections were omitted** and this report still could not be brought within 300 bytes: …

A diff with no sections at all still says it missed its budget: > ⚠ This report could not be brought within 100 bytes.

aburi diff also warns on stderr when it writes such a file, so a caller who asked for a number and got a bigger one hears it from the tool as well as from the document.

Sections are named in document order rather than in the order they gave way: the reader is looking for a heading that is not there, or reading one that is short, and that is the order they looked in.

The budget is measured on the whole document in UTF-8 bytes, note included — naming one more section makes the note longer, so a budget checked against a note that does not yet say what it will say is missed by exactly the length of the last name.

aburi diff --max-bytes <n> is the CLI spelling (cli-spec.md §6.2). The GitHub Action passes 65507 by default: the 65536-byte limit less the 29-byte marker line the upsert prepends (github-action.md §5.2).

7. aburi explain <id> — single Symbol ​

Renders L2 standalone. Written to out/symbols/<sanitized-id>.md or to stdout.

md
# `InvoiceService.createInvoice` *(method)*

**Component**: billing
**File**: `apps/billing/src/InvoiceService.ts:42-91`
**Visibility**: public
**Language**: ts

## Boundary
`@Post('/invoices')` `@UseGuards(AuthGuard)`

## Signature
```
(customerId: CustomerId, items: LineItem[]) → Promise<Invoice>
throws: CreditLimitExceeded
async
```

## Rules
- guard: `customer.creditLimit < invoice.total` (L58)
- throw: `new CreditLimitExceeded(customer.id, invoice.total)` (L60)
- return: `{ ...invoice, status: 'created' }` (L80)

## Effects
- db.write: `prisma.invoice.create` (L75) [effects-prisma]
- event.publish: `eventBus.emit` (L78) [effects-nest] ⚠ medium

## Calls
- `pricing.calculateTotal` (L70)

## Derived by
- `framework:nestjs:controller`
- `branch-condition`
- `throw-statement`
- `effects-plugin:prisma:write`

## Fingerprint
- api: `9ee77913af43`
- logic: `7ecf8c1cebe7`
- syntax: `a3f2e1d0c9b8`

The title carries the confidence badge when the Symbol's confidence is not high (§3.5), as every Symbol heading does.

The default output of aburi explain is stdout; --output <path> writes to a file.

When a dropped symbol is explained:

md
# `CreateInvoiceDto` *(class)* — dropped

**Component**: billing
**File**: `apps/billing/src/dto/create-invoice.dto.ts:1-8`
**Drop reason**: pure DTO

(dropped symbols carry no rules/effects/calls/fingerprint, so there are no detail sections)

8. Sanitization ​

File names under out/symbols/<id>.md sanitize the symbol id:

  • : → -
  • / → -
  • # → -
  • . → -
  • consecutive - compressed to one

Example: ts:apps/billing/src/InvoiceService.ts#InvoiceService.createInvoice → ts-apps-billing-src-InvoiceService-ts-InvoiceService-createInvoice.md

Collision (different ids sanitize to the same file name) → an id hash is appended as a suffix: → ...-createInvoice-<6hex>.md

The hash algorithm is the same as for fingerprints:

  • the first 3 bytes of SHA-256(UTF-8(original Symbol.id)) as lowercase hex (= 6 chars) form the suffix
  • the same id therefore always gets the same suffix, keeping file generation deterministic

9. Mermaid diagrams (optional) ​

Emitted in the L0 workspace.md ## Component dependencies section (see §4.2). Slice View has its own ## 🧵 Slice View section within out/diff.md; see slice-view.md §12 for the rendering conventions.

  • graph LR (left → right) is used for Component dependencies
  • every component in ir.components renders as a node, including isolated ones with no incident edges
  • omitted above 100 nodes measured against the union of declared components and edge endpoints (falls back to the text bullet list)
  • a text bullet list accompanies the mermaid whenever there is at least one edge, so reviewers still see the dependency inventory even when GitHub's mermaid fails to render

Turning mermaid output off entirely via config (e.g. output.mermaid: false) is planned; the config-schema decision is pending.

10. Localization ​

All Markdown projection output is English, with fixed wording.

  • There is currently no i18n mechanism; every heading, label, and message is a fixed English string.
  • Section headings and labels are stable identifiers: CI pipelines and reviewers can rely on their exact text (e.g. matching ## Dropped or the diff section titles).
  • Fixed wording also keeps the projection deterministic and portable across environments and teams.

11. Verifiable properties ​

IDInputExpected
MP1Projecting the same IR twiceCompletely identical Markdown
MP2Reordering the IR, then projectingIdentical Markdown (due to the ordering conventions)
MP3Symbol with empty effects[]No Effects section is emitted
MP4Dropped symbols present in the workspaceDropped section shown folded
MP5Effect with confidence=mediumGets the ⚠ medium badge
MP6Effect with confidence=highNo badge
MP6aSymbol with confidence=medium or low, on the component page, in a diff.md entry or its names-only row, and in explain (kept or dropped)Every heading and names-only row naming it carries the badge after the kind
MP6bSymbol with confidence=highNo badge on any heading or names-only row
MP7mermaid nodes > 100Falls back to the text bullet list
MP8aburi explain <dropped-symbol>Drop reason shown, no detail sections
MP9Symbol id containing slashes/colonsWritten under a sanitized file name
MP10diff where only delta.syntaxChanged is trueClassified into the Syntax-only section (folded)
MP10adiff where delta.confidenceChanged is true and neither apiChanged nor logicChanged isClassified into the Confidence changes section, with a - confidence: <base> → <head> row
MP11diff containing a moved+changed symbolMoved + Changed section (not folded)
MP11adiff containing a moved or moved+changed Symbol whose base and head files are the sameEvery place the move is written (the Moved + Changed entry and its names-only row, the folded Moved list, a Slice member's follow-up) names the file once with the base and head names and lines, never the path twice. A move between files still writes both paths
MP120 components (empty IR)workspace.md is emitted, but the Components table is empty
MP13diff projected with maxBytesResult is at most that many UTF-8 bytes, except where the title, the Summary line and the note alone exceed the budget — which is not achievable, and says so in the note instead. A section is omitted only when it cannot fit, at its smallest, beside every more important section at theirs; among the sections with a names-only form, the full ones come first and every one after the first short one is short or omitted. A note names the short ones and the omitted ones apart
MP13adiff of a large refactor (+142 · −302 · ~326 · 25 moved · 44 moved+changed) projected with maxBytes: 65507Fits, and every removed Symbol is named with its file:line
MP14Symbol block with Rules, Effects, Calls and a fingerprint, the last rule inline or fencedA blank line before **Effects**:, **Calls**: and the <sub> line in both cases; none before a list or after the last section

12. Design decisions ​

12.1 L1 + L2 combined into one component file ​

See §2.1. Per-symbol files carry the major downside of file-count explosion.

12.2 Keeping dropped entries, folded ​

The fact of a drop is retained for transparency, but kept out of the reviewer's primary view. A <details> fold satisfies both.

12.3 No badge for confidence=high ​

Only medium/low get badges, as a notice relative to the default. The reverse would put a badge on every high-confidence entry, creating visual noise.

12.4 Section order fixed by importance ​

Creates a state where a reviewer can simply read from the top. API changes come first; Syntax-only sits folded at the bottom.

12.5 Text fallback accompanying mermaid ​

mermaid can fail to render on GitHub (large diagrams / syntax errors), so a text bullet list is emitted alongside and no information is lost.

12.6 English section headings ​

Accounts for international teams and OSS usage. All projection output is fixed-wording English (§10).

12.7 Why fingerprints are displayed ​

Unnecessary during review, but valuable for debugging and for explaining "why did this appear in the diff". The <sub> rendering keeps the display unobtrusive.

12.8 Whether to use emoji ​

Emoji in section headings (⚠ / 🔧 / ➕ etc.) serve visibility. Only emoji that are CommonMark-compatible and render stably on GitHub are used. A config to disable them by preference (output.emoji: false) will be considered for a future release (see the roadmap).

12.9 Dropping sections rather than truncating the string ​

A byte budget can be met by cutting at the last code point that fits, and for most documents that would be the smaller change. Not for this one: its sections are <details> blocks and fenced code, and a cut inside either produces Markdown that renders as one unclosed element eating everything after it — a report that looks broken rather than shortened, and says nothing about what is missing. Dropping whole sections costs the least important content, keeps every remaining block well-formed, and leaves a note that names what went and how to get it back. §12.4's importance order is what makes the choice of victim obvious: it already ranks the sections, so the cap keeps them from the top.

Dropping a section is still the most expensive thing the cap does, because the reader is left with a heading's name in the note and nothing else. For the sections whose entries are whole Symbols there is a cheaper step first: the names and locations, without the entries. That keeps the one thing a reviewer cannot recover from the Summary — which Symbols changed — and it is what a failing --fail-on removed check needs the report to still say. So each section is first kept in its smallest form, and only then are the lists made full again, from the top, as far as the budget allows. The alternative ordering, keeping more sections full and dropping the lists below them, is the one that left a 302-removal report naming none of the 302.

Released under the Apache License 2.0.