IR Schema (aburi.ir.v1)
Schema definition of the Intermediate Representation (IR) emitted by Aburi. The JSON Schema at schema/aburi.ir.v1.json is the single source of truth; this document states the rationale behind the design decisions.
1. File Format, Ordering, and Key Presence
- Format: JSON (UTF-8, LF)
- Indentation: 2 spaces (default), single line with
--compact - Top-level keys: ascending, by UTF-16 code unit
- Array ordering (fixed for diff stability):
components[]: ascending byidsymbols[]: ascending byiddependencies[]: lexicographic by (from,to,via)publicApi[]within a Component: ascending by valuestats.skippedFiles[]: ascending bypathdecorators[]/rules[]/effects[]/calls[]within a Symbol: ascending byline(source order within the same line). Propagated effects are the one exception and sit in their own segment, ordered by (id,target) - see section 9.4
Every string ordering above is by UTF-16 code unit, not by locale and not "alphabetical". Within the Basic Multilingual Plane that coincides with codepoint order; astral-plane strings differ, but the serializer, the integrity checker and every consumer using the default < / > operators or Array.prototype.sort all agree on it, which is what matters.
The ordering convention is a precondition for diff stability. Spurious diffs caused by array order must never occur.
1.1 Absent key vs explicit null
An absent key and an explicit null are not interchangeable. Every optional property fixes one of the two as its way of saying "no value", and which one it is follows mechanically from the property's type:
A property whose type admits
nullis Class A (always-written). A property whose type does not admitnullis Class B (presence-carrying). No property may be both. A field that could be absent,null, and value-carrying would spend three states on two meanings; v1 contains none, and none may be added.
Class A — the value is unknown or does not apply, but the field itself always belongs on the record.
- A writer MUST emit the key on every record, carrying
nullwhen there is no value. It MUST NOT omit the key and MUST NOT substitute a placeholder (0,"",[]). - A reader MUST treat an absent key exactly as
null. It MUST NOT read absence as a state distinct fromnull.
The reader rule is what carries backward compatibility, and it is the more important of the two. A writer rule only governs code not yet written; documents already committed to a user's repository cannot be rewritten, and aburi diff reads a committed IR as its base (cli-spec.md §6.3). Because absence and null are indistinguishable to a conforming reader, an older document that omits a Class A key stays correct rather than becoming a special case. The ?? null normalizations throughout the core, diff, and projection packages are this rule's implementation, not defensive clutter.
Class B — the presence of the key is itself the information: "this pass ran", "this entry was propagated", "this document is new enough to carry the field".
- A writer MUST omit the key entirely when the condition does not hold. It MUST NOT substitute
[],false, ornull, all of which would erase the distinction the field exists to draw. - A reader MAY branch on
Object.hasOwn. "Absent" and "empty" are different facts here.
| Field | Type admits null | Class | Writer rule |
|---|---|---|---|
generatedAt | no | B | reserved for the producer's clock; no writer emits it today, so the key is always absent. --no-timestamp currently suppresses only the Markdown projection's "Generated" line |
stats.effectClassifyTimeouts | no | B | omitted when no classification timed out |
stats.lspEnrichment | no | B | omitted when the LSP pass did not run |
stats.lspEnrichment.hintsProduced | no | B | emitted by the current pipeline whenever the enclosing record is; absence means the document predates the counter |
stats.lspEnrichment.hintsConsumed | no | B | as above |
stats.lspEnrichment.hintsRejected | no | B | as above; emitted with all five buckets at 0 rather than omitted when nothing was rejected |
stats.callResolution | no | B | always emitted by the current pipeline; absence means the document predates the counter |
stats.skippedFiles | no | B | omitted when the scan lost nothing; absence with totalFiles > parsedFiles means the document predates the field |
Component.publicApi | no | B | omitted when empty |
Component.frameworks | no | B | omitted when empty |
Component.description | yes | A | always emitted; null when the component carries no description |
Symbol.component | yes | A | always emitted; null when the Symbol lies outside every Component |
Symbol.signature | yes | A | always emitted; null for Symbols with no callable signature |
Signature.inferredThrows | no | B | omitted when nothing was inferred; never [] (§7) |
Effect.line | no | B | present iff the entry is locally detected (schema-enforced by the allOf's required/forbidden flip, §9.4) |
Effect.propagated | no | B | present iff true. Convention only — the schema's if treats false and absent alike, so propagated: false validates while violating this rule (§9.4) |
Effect.derivedFrom | no | B | present iff propagated is true (schema-enforced by the same flip, §9.4) |
SourceRange.startColumn | yes | A | always emitted; null until LSP enrichment fills it (§12) |
SourceRange.endColumn | yes | A | always emitted; null until LSP enrichment fills it (§12) |
None of the Class A fields appear in required. That is a consequence of the v1 freeze (§15.2 makes optional → required breaking), not a statement about their meaning; §15.4 records the promotion as a v2 candidate.
Most of the table is convention that the schema cannot express, and the two rows marked schema-enforced are the exception rather than the rule: JSON Schema can say "this key is forbidden here and required there", which is what pins Effect.line and Effect.derivedFrom, but it cannot say "prefer absence over a null that validates". A document that breaks a Class A or Class B rule is therefore usually still a valid aburi.ir.v1 document — the rules exist so that consumers do not have to handle both spellings, not so that validators reject one.
How this reaches the JSON. The canonical serializer drops object properties whose value is undefined and preserves null verbatim. A TypeScript undefined therefore is the Class B omission — which means assigning undefined to a Class A field is a convention violation that changes the emitted bytes while still type-checking. Nothing else in the pipeline rewrites key presence, so what a writer assigns is exactly what lands on disk.
Why the schema does not use "default": null. JSON Schema's default is an annotation; it does not participate in validation. Writing it would look like a declaration that absence means null while no validator treats it that way. The rule lives here and in each property's description instead.
Adding an optional field to v1 means adding a row to the table above and stating the class in the property's description in schema/aburi.ir.v1.json. An optional property with no description has not declared its class, and packages/types/test/schema-conventions.test.ts fails on it.
1.2 Unicode normalization
Every string in a Document is in Unicode NFC. The canonical serializer normalizes on write, so this is not a preference: it is the form the bytes on disk are in, and anything else means the Document says one thing and its producer believed another.
The rule is load-bearing rather than tidy, for two reasons.
Ordering. Six of §1's orderings are on strings: components[] and symbols[] by id, dependencies[] by (from, to, via), publicApi[] by value, stats.skippedFiles[] by path, and the propagated segment of effects[] by (id, target) per §9.4. Every ordering decision in the pipeline compares the string held in memory, while the serializer writes the normalized one. Where the two differ, a Document satisfies the sort rule in memory and lands on disk violating it — é written as one codepoint and as e plus a combining acute sort on opposite sides of z, and are the same name.
Identity. Two spellings of one value are two entries: two Symbol ids for one construct, two effect targets for one table, a rename degraded into a delete plus an add. Which spelling a string arrives in is decided by how the text was created — an archive, an HFS+ volume, a git checkout of a name someone typed on a Mac — and it survives copying to any platform, so one source tree can hand back both.
Normalization therefore happens where a string enters the process, not where it is compared:
| Entry point | What it normalizes |
|---|---|
makeSymbolId / trySymbolId | the three id parts, before validating them, so the ids the guard accepts are the ids the constructors can mint |
toDocumentPath | every path the file walk produces — symbols[].source.file, and stats.skippedFiles[].path for one the walk gave up on |
toRelativePosix (workspace detection) | components[].roots and workspace.managers[].roots |
resolveComponents (CLI config load) | those same fields when they come from aburi.json rather than from detection |
normalizePackagePath (component detection) | components[].publicApi |
| the scan pipeline's plugin boundary | symbols[].source.file, signature.inputs[].name, the import edges the call resolver matches, and the call target that becomes calls[].target or effects[].target |
buildDropCFilter | the suppress / keep / dropCallees prefixes those call targets are matched against |
Normalization is all these entry points do to a path. None of them converts a native path into a POSIX one, because the conversion is not decidable from the string: \ is a separator on Windows and an ordinary filename character on POSIX, so a rewrite applied to every path silently renames any file whose name holds one. A caller that knows it holds a native path converts it — toRelativePosix does, on the platform separator, which is a separator exactly where a filename cannot hold one — and everything else hands over a path that is already POSIX and is validated as it stands.
A normalized path is not a filename. What comes out of these entry points addresses the Document, and a filesystem that stores the name it was given — NTFS, ext4 — does not answer to it: a stat or a readFile under the normalized spelling of a decomposed name misses, and a file:// URI built from it names nothing. Whatever opens a file keeps the spelling the filesystem handed back, which is why DiscoveredFile carries both and only path reaches the Document.
Two names that differ only in composition therefore claim one Document path. That is not a spelling to choose between: the scan withdraws every claimant and reports them, because one path naming two files mints the same Symbol id for both, which invariant #1 refuses. (The mirror case — one file reachable under two spellings, and so holding two ids — is the one #10 exists for, and the one #1 by construction cannot see.)
The last row is not a separate rule: normalization has to be total across a comparison. A value normalized on one side and left alone on the other turns a match into a miss, and these misses are quiet - a suppressed call reappears in the Document, a resolved edge points at an unrelated Symbol, a call lands in the no-match diagnostic bucket instead of external.
Normalizing at the comparator instead would fix an ordering and leave the two spellings in the Document as two entries, which is the larger of the two problems.
§14 invariant #19 checks this on a Document read off disk, scoped to the strings whose spelling decides an order or an identity. Two categories sit outside it:
- Values #17 already refuses a non-NFC spelling of -
components[].id, whose grammar is ASCII kebab-case and so leaves nothing for NFC to change, andsymbols[].id, whose constructor checks NFC in its own right rather than as a side effect of an ASCII grammar. Reporting either under #19 as well would have the reader chase one string twice.symbols[].namesat here on the ASCII argument alone and moved onto #19's list when the qualified-name grammar widened to ECMAScript's IdentifierName — a decomposedcafénow satisfies the grammar, so only #19 tells the two spellings apart. - Strings the Document quotes - a decorator's raw source text, a signature type. Their spelling decides nothing, and rewriting a quotation would misquote it. They still reach disk normalized, since the serializer normalizes everything.
NFC, not NFKC. NFC composes characters that are canonically equivalent: one character, spelled two ways. NFKC additionally folds compatibility characters - fi to fi, fullwidth A to A - which are different characters. Under NFKC two Symbols whose ids differ only by such a character would collapse onto one, and quoted source text would be rewritten into something that never appeared in the file.
Two keys that collide under normalization are a Document error. {"é": 1, "é": 2} with one key composed and one decomposed is JSON a parser accepts and silently collapses, losing an entry, so serializeCanonical refuses it with canonical-key-collision rather than writing it.
2. Top-Level Structure (Document)
{
"$schema": "https://aburi.kage1020.com/schema/aburi.ir.v1.json", // required
"generator": { // required
"name": "aburi",
"version": "1.0.0",
"plugins": [ // required; records ALL plugins (lang/framework/effects)
{ "name": "lang-typescript", "type": "lang", "version": "1.0.0", "grammarRevision": "tree-sitter-typescript@0.23.2" },
{ "name": "framework-nestjs", "type": "framework", "version": "1.0.0", "grammarRevision": null },
{ "name": "effects-prisma", "type": "effects", "version": "1.0.0", "grammarRevision": null }
]
},
"generatedAt": "2026-06-19T15:30:00Z", // Class B (§1.1); no writer emits it today
"workspace": { // required
"root": ".",
"managers": [ // required (empty array allowed)
{ "tool": "pnpm", "roots": ["packages/*", "apps/*"] }
],
"languages": ["ts"] // required (short-form ids declared by lang plugins, e.g. ts/tsx/py/go/rs)
},
"components": [ /* Component[] */ ], // required
"symbols": [ /* Symbol[] */ ], // required
"dependencies": [ /* Dependency[] */ ], // required
"stats": { // required
"totalFiles": 18,
"parsedFiles": 18,
"keptSymbols": 27,
"droppedSymbols": 7,
"effectPropagation": { // required — emitted even when nothing propagated
"sccCount": 27, "maxSccSize": 1, "propagatedEffectCount": 4, "symbolsWithPropagatedEffects": 2
},
"callResolution": { // Class B (§1.1); always emitted by the current pipeline
"totalCalls": 131,
"resolvedCalls": 120,
"unresolved": {
"localScope": 0, "external": 6, "dynamic": 4, "ambiguous": 0, "noMatch": 1
}
},
"skippedFiles": [ // Class B (§1.1); omitted when nothing was lost
{ "path": "src/route.ts", "reason": "parse-failed" }
]
}
}$schema: canonical URL. Used for IDE JSON Schema resolution and integrated validationgeneratedAt: operational metadata of the producer. Excluded from fingerprint computation. Class B per §1.1 — the key is omitted rather than nulled when there is no timestamp to record. No writer emits it today: the scan pipeline never sets it, and--no-timestampcurrently reaches only the Markdown projection, where it suppresses the "Generated" line. A producer that does record a clock must drop the key when committing the IR, so that re-scanning an unchanged workspace produces an unchanged fileworkspace.root: always".". Never write absolute paths (IR portability)workspace.managers[].tool: runtime-independent string. Representative values:pnpm/npm/yarn/bun/uv/poetry/pip/cargo/go/mvn/gradle/hatch/pixi. Unknown values are not rejected (so that adding a new tool never requires a schema revision)stats: for human/CI logs. Excluded from fingerprintstats.skippedFiles: every file the scan gave up on, and why — the projection ofScanResult.skippedinto the Document. Sorted by path; invariant #21 holds the length tototalFiles - parsedFiles. Without it the only trace of a loss istotalFiles > parsedFiles, which names no file and is equally true of an over-size file, a timed-out one and a withdrawn one — soaburi diffreads a Symbol's absence as a deletion and reports API nobody removed. Optional so documents produced before the field existed remain valid v1; the current pipeline always emits it when anything was lost, so absence withtotalFiles > parsedFilesmeans "this IR predates the field", not "nothing was lost". Nodetail: the scan carries one per entry, but theunreadabledetail is an OS error message containing the absolute path, and a canonical document whose bytes depend on where the repository was checked out is not byte-stablestats.callResolution: the call-resolution census ofcall-resolution.md§8.1 — how many call sites the resolver saw, how many it identified, and why the rest stayednull. Optional so documents produced before the field existed remain valid v1; the current scan pipeline always emits it, so absence means "this IR predates the counter", not "nothing was unresolved". The per-call reasons behind these counts are deliberately not persisted — see §8.1
3. Symbol ID Convention
3.1 Format
<language>:<file-path>#<qualified-name><language>: identifier declared by the language plugin (ts,tsx,js,py,go,rs, ...)<file-path>: POSIX path relative to the workspace root (forward slashes enforced). §14 invariant #10 states the rule every path in the Document obeys; a file path adds two restrictions of its own, because it is part of an id: it holds neither:nor#(the id is split on the first of each), and it is never the bare.(that names the workspace root, and a directory holds no Symbol)<qualified-name>: name unique within the file..and::are separators that join two named constructs, so every segment they delimit is a non-empty identifier:A.,.A,A..Band::are not qualified names.<default>(§3.3) is the one reserved exception to the identifier rule. "Identifier" is ECMAScript's IdentifierName —ID_Start/ID_Continueplus$,_, ZWNJ and ZWJ — soユーザー取得andcaféare qualified names, and a destructuring pattern's text or a computed member's brackets are not.Symbol.name(§5) carries a qualified name too and obeys the same grammar — it is whatapiFingerprintreduces to a short name, and nothing in the Document ties it to the qualified name inside the id, so §14 invariant #17 checks both
3.2 Building the qualified name
| Symbol | qualified name |
|---|---|
| top-level function / const / var | createInvoice |
| class | InvoiceService |
| instance method | InvoiceService.createInvoice |
| static method | InvoiceService::fromJson |
| nested namespace / class | Billing.Invoice.create |
| interface / type alias | Invoice |
| default export (including anonymous functions/classes) | <default> |
| function/class expression assigned to a variable | the variable name becomes the qname (e.g. const handler = () => ... → handler) |
| destructuring declaration | one Symbol per binding, each named by the binding (e.g. const { GET, POST } = handlers → GET and POST). The pattern's text is not a name; { a: b } binds b, and { a = fallback } binds a and reads fallback |
class member with a computed name ([Symbol.iterator]() {}) | no Symbol, and no diagnostic. The brackets are not a name static analysis can record, and mangling them into a segment would invent one the source does not contain. A call target answers the same shape differently and for the same reason (lang-plugin.md §4.4): a qualified name is a resolution target, so an invented segment mints an id that names nothing, while a call target is a reading of what the source calls, which a dropped segment falsifies — hence <computed> there and no Symbol here |
class member with a quoted name ("createInvoice"() {}) | the segment the literal decodes to, when that is an identifier — a property key is a string, so "createInvoice" and createInvoice are one member and fold onto one Symbol |
class member with a quoted name that is not an identifier ("a-b"() {}), or a numeric one (1() {}) | no Symbol, and no diagnostic, as for a computed name. C["a-b"] is how the source addresses it and C.a-b is not a qualified name; the body stays on the class |
class member whose name is written with a unicode escape (\u006bay() {}) | no Symbol. The grammar is IdentifierName less the escape forms, and a bare name arrives as its own source text with the backslash still on it. Quoted, the same key decodes and is a member — one property spelled two ways, answered two ways |
class member whose name the parser recovered rather than read ("\uZZZZ"() {}) | no Symbol. A literal that failed entirely leaves no literal behind: recovery re-emits the surviving characters as a plain name, and believing it would record a member the source does not spell |
3.3 Handling anonymous symbols
Anonymous symbols do not become independent Symbol entries. Callbacks, immediately-invoked function expressions, and anonymous function arguments are absorbed into the parent Symbol's calls / effects / rules. Position-dependent IDs (of the <anon@L42> kind) must not be used (they break diff stability).
The only exceptions are <default> and "function expressions assigned to a variable" from §3.2. These are named entry points, so they get a Symbol.
3.4 ID stability
- IDs generated by the same Aburi version for the same input match exactly
- Renaming parameters or local variables does not change the ID
- Moving a file changes the ID (the
<file-path>part changes) → the Diff algorithm assigns themovedstatus via git rename + fingerprint matching
3.5 ID namespaces
Aburi mints three kinds of identifier, and each owns a namespace the other two must not reach into:
| Identifier | Shape | Minted by |
|---|---|---|
| Symbol ID | <language>:<file>#<qname> (§3.1) | makeSymbolId / trySymbolId in @aburi/core |
| Component ID | ASCII kebab-case (§4) | makeComponentId in @aburi/core |
| Slice ID | "slice:" + <anchor Symbol ID> (slice-view.md §7.1) | sliceIdFor in @aburi/diff |
Two rules keep the namespaces from overlapping:
sliceis a reserved language token. A language plugin claiming it would mint Symbol IDs shaped exactly like Slice IDs, and deriving a Slice ID from one of those would produceslice:slice:….makeSymbolIdrejects the token; see multi-language-id.md Rule L-11.- The three types are nominal, not structural. On the wire all three are JSON strings, and JSON Schema has no way to say otherwise.
@aburi/typeslayers a brand onto each generated alias soSymbolId,ComponentId, andSliceIdare mutually non-assignable in TypeScript and a barestringsatisfies none of them.
A brand is minted only by the constructors above. Assertions (x as SymbolId) live in four documented places and nowhere else, and a test in @aburi/e2e-integration fails if a fifth appears under packages/*/src:
| Where | Why |
|---|---|
packages/core/src/id.ts | The two id constructors. Both run the full grammar check first |
sliceIdFor in packages/diff/src/slice.ts | The only SliceId constructor |
sliceRecordViolation in packages/diff/src/slice.ts | Takes unknown by contract — it inspects records that have not been type-checked |
readIR in packages/cli/src/ir-io.ts | Brands a whole parsed document at once (as unknown as IR), which is the only way to type a JSON parse. Invariant #17 in §14 is what checks the ids inside it |
| per-package test fixtures | A case that feeds a malformed id to the code that rejects it has to be able to write one |
The brands are erased at runtime and are absent from the JSON. They constrain what the codebase can build, not what a document may contain — shape checking on the wire remains the job of the schema and of §14.
dependencies[].from / .to are typed SymbolId | ComponentId because §11 lets a single array hold both endpoint kinds. Which one a given endpoint is gets recovered from its shape. Two flavours of that test exist and they are not interchangeable: isSymbolId / isComponentId in @aburi/core answer "is this a well-formed id?" and narrow, while the integrity checker and the Markdown projection use deliberately looser silhouette tests that return a plain boolean — a malformed endpoint still has to be routed to the Symbol-id invariants so the breach is reported, and handing it the brand would break the property that holding a SymbolId means having gone through a constructor.
4. Component
A logical boundary of the monorepo. Independent of physical packages.
{
"id": "billing", // required, unique, ASCII kebab-case
"name": "Billing", // required, human-facing label
"roots": ["apps/billing", "packages/billing-domain"], // required, POSIX relative
"publicApi": [ // optional, Class B (§1.1) — omitted when empty
"apps/billing/src/routes/**",
"ts:packages/billing-domain/src/index.ts#Invoice"
],
"languages": ["ts"], // required, short-form lang ids
"frameworks": ["nestjs"], // optional, Class B (§1.1) — omitted when empty
"description": null // Class A (§1.1) — always present, null when unset
}descriptionis Class A per §1.1 — always written,nullwhen nothing supplied it. Only the config path (components[].description) can supply one today; automatic detection has no source for it and always writesnullidis fixed to ASCII kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$) so it can be used in URLs and CLI arguments. A segment may start with a digit: the id is derived by kebab-casing a package or directory name (component-detect.md §4.1), and3d-force-graphis an ordinary npm package name- Each element of
publicApiis either a glob or a symbol id - Physical Component boundary inference automatically reads package manager configuration (
pnpm-workspace.yaml,turbo.json,go.work,Cargo.tomlworkspace,pyproject.toml/uv workspaces, etc.); see the separate documentcomponent-detect.mdfor details
5. Symbol
The core entity of the review unit.
{
"id": "ts:apps/billing/src/InvoiceService.ts#InvoiceService.createInvoice", // required
"kind": "method", // required, enum §5.1
"extKind": null, // required, nullable; language extension §5.2
"name": "InvoiceService.createInvoice", // required, qualified name
"language": "ts", // required, short-form lang id (e.g. ts / tsx / py / go / rs)
"component": "billing", // Class A (§1.1) — always present, null = outside any component
"visibility": "public", // required, enum §5.3
"decorators": [ /* Decorator[] */ ], // required
"signature": { /* Signature */ }, // Class A (§1.1) — always present, null = no signature
"rules": [ /* Rule[] */ ], // required
"effects": [ /* Effect[] */ ], // required
"calls": [ /* Call[] */ ], // required
"source": { /* SourceRange */ }, // required
"fingerprint": { /* Fingerprint */ }, // required
"confidence": "high", // required, enum §5.4
"derivedBy": ["framework:nestjs:controller", "branch-condition"], // required
"dropped": false, // required
"dropReason": null // required, non-null when dropped=true
}component is Class A per §1.1, so the key is on every Symbol and null means "outside every declared Component". The scan assigns it from components[].roots[] alone: the Component whose root is the longest path-segment prefix of symbols[].source.file claims the Symbol, and a file under no root at all carries null — the rule, its tie-break, and what it does not attempt are component-detect.md §12. Dropped Symbols are attributed like any other: a drop says something about a Symbol's shape, not about where it lives.
5.1 kind (core enum)
"function" | "method" | "class" | "interface" | "type" | "const" | "module" | "namespace" | "variable" | "enum" | "constructor"
Anything outside the core enum is expressed via extKind. A consumer may treat an unknown kind as an error (= strict enum).
5.2 extKind (language extension)
Either null or a string of the form <namespace>(:<segment>)+. At least 2 segments, arbitrarily deep. The namespace is a language/paradigm identifier:
| namespace | examples | owning plugin |
|---|---|---|
fp:* | fp:match, fp:adt, fp:effect | functional-language plugins |
oop:* | oop:abstract, oop:trait | OOP extension plugins |
meta:* | meta:macro, meta:proc-macro | macro-language plugins |
framework:* | framework:nestjs:guard, framework:react:hook | framework plugins |
When extKind is non-null, kind must hold the closest core kind (kind: "function" when extKind: "fp:match"). Consumers that only read the core vocabulary can ignore extKind.
5.3 visibility (enum)
"public" | "private" | "protected" | "internal" | "package"
public: explicit export or public modifierprivate/protected: in-class visibility as-isinternal: visible within the workspace but not exposed externallypackage: visible within the monorepo package but not exposed outside the component
5.4 confidence (enum)
"high" | "medium" | "low"
Criteria:
| Value | Criterion |
|---|---|
high | Explicit in the AST (export modifier, throw statement, if statement), or explicitly declared by a framework/effect plugin |
medium | Identifier match (inferring db.write from prisma.invoice.create), or determination from naming conventions (*Service, *Controller) |
low | Heuristic (symbol connectivity or file location is the only evidence) |
Symbols with low confidence get a badge in the Markdown projection so reviewers clearly know the machine is not confident.
5.5 derivedBy (evidence)
A string array indicating why this symbol was extracted in this form. Each entry takes one of the following forms:
<rule>— a core extraction rule (branch-condition,throw-statement,export-keyword)framework:<name>:<role>— determined by a framework plugin (framework:nestjs:controller)effects-plugin:<name>:<action>— determined by an effect plugin (effects-plugin:prisma:write)convention:<name>— determined by a naming/structural convention (convention:service-suffix)
An empty array means "picked up automatically by the core extraction path".
5.6 dropped
false(default): included in the IR outputtrue: judged to be decoration, but kept for transparency about what was dropped
Symbols with dropped: true have rules/effects/calls/fingerprint set to their prescribed values (empty arrays / all fingerprints = "0"*12). The Markdown projection aggregates them in a collapsed ## Dropped section (see markdown-projection.md §3.6). aburi explain outputs the full details. dropReason is a short human-readable phrase (e.g. "pure DTO", "logger boilerplate", "generated file").
6. Decorator
{
"name": "Post", // required
"raw": "Post('/invoices')", // required, verbatim source
"arguments": ["'/invoices'"], // required, string representations of the arguments
"boundary": true, // required
"line": 14 // required
}The boundary: true determination is made by the framework plugin. The Aburi core does not hardcode it.
7. Signature
{
"inputs": [
{ "name": "createInvoiceDto", "type": "CreateInvoiceDto" }
],
"outputs": ["Promise<Invoice>"],
"throws": ["CreditLimitExceeded"],
"inferredThrows": ["NetworkError"], // Class B (§1.1): absent unless LSP enrichment inferred something
"async": true,
"generator": false,
"typeParameters": []
}typeis the string representation as read from the AST. No type resolution is performed (LSP enrichment may optionally normalize it)throwscombines explicit throw statements and JSDoc@throwsinferredThrowsholds throws the LSP enrichment pass read off the callees' declared signatures (lsp-enrichment.md §7.1). It is a field of its own rather than an addition tothrowsprecisely so that turning LSP on never perturbs theapifingerprint, whose input list namesthrowsand notinferredThrows(fingerprint.md §3.1)inferredThrowsis Class B per §1.1: when the pass inferred nothing — because no callee declared a throw, or because it fell back — the key is omitted outright. It is never emitted as[], and the schema enforcesminItems: 1so that an empty array cannot be written by accident- A Symbol's entire
signaturemay benull(class bodies, whole interfaces). It is Class A per §1.1, so the key is present on every Symbol
8. Rule
Semantically meaningful branches, exceptions, loops, and compound returns in the control flow.
{
"type": "guard", // required, enum §8.1
"line": 58, // required
"condition": "customer.creditLimit < invoice.total", // type=guard/switch/match only
"what": null, // type=throw only
"expr": null, // type=return only
"loopKind": null // type=loop only ("for"|"while"|"do")
}8.1 type (enum)
"guard" | "throw" | "return" | "loop" | "try" | "switch" | "match"
guard: anifstatement containing an early return / throw / continuethrow: a throw statementreturn: any non-trivial return (trivial determination perdrop-list.md)loop: for / while / dotry: try-catch (rules inside the catch body are not expanded into the same Symbol's rules)switch: a switch statementmatch: pattern matching (used only in symbols withextKind: "fp:match")
8.2 Extraction conventions
- The same AST node must not produce multiple Rules
condition/what/exprare whitespace-normalized (consecutive whitespace collapsed to one, newlines removed, trailing...when over 120 characters)- Simple returns such as
return x/return truedo not become Rules (inclusion follows the trivial determination indrop-list.md)
9. Effect
The result of side-effect detection.
{
"id": "db.write", // required, effect tag §9.1
"target": "prisma.invoice.create", // required, callee string
"line": 75, // required
"plugin": "effects-prisma", // required, detection source
"confidence": "high" // required, same enum as Symbol
}9.1 Core effect vocabulary
A fixed set in namespace:action form. Only concepts that hold universally across any runtime/language.
| Category | Values |
|---|---|
db.* | db.read, db.write, db.transaction, db.migration |
network.* | network.http, network.ws, network.rpc |
queue.* | queue.publish, queue.consume |
event.* | event.publish, event.subscribe |
fs.* | fs.read, fs.write |
state.* | state.mutate, collection.mutate |
time.* | time.now, time.timer |
random | random |
env.* | env.read, env.write |
process.* | process.exit, process.signal |
Within schema version aburi.ir.v1, this set is additive-only; deletion and semantic change are forbidden.
9.2 Plugin-extended effects
Effects specific to a particular runtime/library/domain are declared by the plugin with the x-<plugin>:<action> prefix.
Examples:
x-stripe:chargex-s3:uploadx-nest:lifecycle.on-module-initx-auth:permission-checkx-react:state-update
Consumers must tolerate unknown x- effects; the Markdown projection sections them by prefix.
9.3 Separation of Effect and Call
If a call_expression is recognized by an effect plugin, it is recorded in effects[] only, not in calls[]. No duplicate output.
9.4 Propagated effects
Effect records may carry the optional fields propagated: boolean and derivedFrom: SymbolId[] when produced by the effect-propagation pass; see effect-propagation.md §5. On entries with propagated: true, the line field is omitted from the JSON output — not set to null, not set to a placeholder — because the effect originates N hops away and has no line in the containing Symbol's body. The JSON Schema (aburi.ir.v1.json) narrows line accordingly: required when propagated is absent or false; forbidden when propagated is true. These extensions are non-breaking under §15.2.
Ordering. Within one Symbol, effects[] is in two segments: the locally-detected entries first, ascending by line per §1, then the propagated entries, ascending by (id, target). A propagated entry has no line to order on, so it orders on the pair that identifies it. That makes target a sort key, which is why §1.2's normalization rule reaches it - the two spellings of one target sort differently and the serializer writes only one of them.
propagated is itself Class B per §1.1, so a writer records a locally-detected entry by omitting the key, never by writing propagated: false. The schema's condition is propagated present and true, which puts absent and false on the same branch — so a false validates and is read correctly by every consumer, but it spends a key to say what absence already says. The distinction matters when reading these rules together: the schema pins where line and derivedFrom may appear, while the choice between absent and false is convention only.
10. Call
A call that does not qualify as an effect.
{
"target": "pricing.calculateTotal", // required
"line": 70, // required
"resolved": null // optional, resolved symbol id
}resolved is filled in by the call-resolution feature (separate document). null while unresolved.
target is the callee as the language plugin normalized it (lang-plugin.md §4.4), and one segment of it is reserved: <computed> — exported as COMPUTED_TARGET_SEGMENT — stands where the source addressed a property through brackets with something that is not a name, so prisma[model].create() is prisma.<computed>.create. The segment pattern of §3.1 admits no <, so a call carrying it resolves against nothing rather than against whatever the shortened name would have matched. effects[].target (§9) carries the same string under the same rule, and is where a call an effect plugin claimed records it — such a call is not in calls[] (§9.3) and reaches no call-resolution bucket.
A consumer reading a fixed position of a target — the model of a delegate call, the receiver before a verb — meets this segment where a name was expected, and it names none: absent evidence, never a name spelled oddly.
11. Dependency
An edge between symbols or between components. Both endpoint kinds live in the same dependencies[] array — the schema for from/to is a plain string, and the endpoint kind is recovered from the id shape (<language>:<file>#<qname> for a Symbol id, ASCII kebab-case for a Component id).
{
"from": "billing", // required, symbol id or component id
"to": "pricing",
"via": "import", // required, enum
"direction": "outbound", // required, enum
"effect": null // optional, related effect tag
}11.1 via (enum)
"import" | "call" | "inherit" | "implement" | "compose" | "http" | "event" | "sql"
"call" is reserved for symbol-to-symbol edges emitted from the resolved call graph (see call-resolution.md §7). Symbol-to-symbol Dependencies are always emitted with direction: "outbound" and effect: null. Per-edge confidence and caller-site line are held on the resolver's internal CallEdge shape (call-resolution.md §7.1) and deliberately NOT projected onto Dependency — Call.resolved is SymbolId | null alone. The caller-site line still survives on Symbol.calls[].line; per-edge confidence is not persisted at all in v1 and is a candidate for a Call.confidence extension in a later phase.
11.2 direction (enum)
"outbound" | "inbound" | "bidirectional"
The direction as seen from from. bidirectional is limited to cases such as bidirectional RPC.
12. SourceRange
{
"file": "apps/billing/src/InvoiceService.ts", // required, POSIX relative
"startLine": 42, // required, 1-based
"endLine": 91, // required
"startColumn": null, // Class A (§1.1), 1-based; null until LSP enrichment
"endColumn": null // Class A (§1.1), 1-based; null until LSP enrichment
}startColumn / endColumn are filled in during LSP enrichment (lsp-enrichment.md §4.2). Until then — and under any LSP fallback (lsp-enrichment.md §6.2) — they are null, not absent: "no column recorded" is a value, not a missing field.
The Tree-sitter tier is not unable to produce a column — the parse tree carries one — but the in-tree TypeScript plugin deliberately does not publish it, so that every column in an Aburi IR comes from textDocument/documentSymbol and one convention about what a column counts. A plugin that has a column and wants to publish it may (lang-plugin.md §4.3); a successful LSP pass overwrites it either way.
They are Class A per §1.1. A writer MUST emit both keys on every SourceRange; a reader MUST read an absent key as null. They stay out of required only because promoting an optional field is breaking under §15.2 — see §15.4.
13. Fingerprint
{
"api": "9ee77913af43", // required, 12 hex
"logic": "7ecf8c1cebe7", // required, 12 hex
"syntax": "a3f2e1d0c9b8" // required, 12 hex
}See the separate document fingerprint.md for the exact computation. This document only stipulates that "there are three 12-hex strings" and that "the fingerprint computation includes no noise other than generatedAt / stats / array order".
Roles of the three axes:
| Axis | Changes when | Invariant under |
|---|---|---|
api | signature / visibility / boundary decorator changes | body implementation changes |
logic | changes to the set of rules / effects | local variable renames / method reordering / comments / added decoration |
syntax | any AST structural change | formatting-only changes |
For symbols with dropped: true, all fingerprints are fixed to the 12-hex string "000000000000".
14. Invariants
Guaranteed by Aburi internals. Item 20 restates the schema's own structural requirements, because nothing in the pipeline runs a schema validator — readIR checks $schema and hands the parsed object to the integrity checker — and the nineteen relational rules above it are statements about a Document of that shape:
symbols[].idis unique within the Documentcomponents[].idis unique within the Document- If
symbols[].componentis non-null, it exists incomponents[].id - If
dependencies[].from/tois in symbol-id form, it exists insymbols[].id - If
dropped: true,dropReasonis non-null confidence∈ §5.4 enumeffects[].idis in the §9.1 core vocabulary or carries anx-<plugin>:prefixkindis in the §5.1 enumextKindisnullor of the form<namespace>(:<segment>)+(at least 2 segments, arbitrarily deep)- Every path —
symbols[].source.file,components[].roots[],workspace.managers[].roots[]— is non-empty, POSIX (forward slash, no backslash), not absolute, free of..segments, and free of.segments except for the bare.that names the workspace root.workspace.rootanchors every path in the Document, so one that ascends past it names something the Document has no way to be about; and./src/a.tsbesidesrc/a.tsis one file with two spellings, which by §3.1 is one file with two Symbol ids that #1 cannot see as a duplicate. This is the rulemakeSymbolIdapplies on the way out, shared rather than restated, so the paths Aburi writes and the paths it accepts are one set. A consequence worth stating: a file whose name holds a backslash — legal on a POSIX filesystem — has no spelling in a Document at all, not even as astats.skippedFiles[].path, so the scan leaves it out oftotalFilesas well and reports it only to the caller. That is unlike:and#, which this rule admits and only §3.1 refuses, which is why a file those disqualify is still recorded by path - The array-ordering conventions (§1) are satisfied
- If
dependencies[].viais"call", bothfromandtoare symbol ids present insymbols[].id(strengthens #4 for call edges — a call edge with a component-id endpoint or a dangling symbol id is rejected outright) - Within
dependencies[], the triple(from, to, via)is unique — the same directed edge cannot be recorded twice - For every
Symbol.calls[]entry with a non-nullresolved, there is a matching Dependency{ from: caller.id, to: resolved, via: "call" }independencies[], and conversely everyvia: "call"Dependency corresponds to at least one such Call entry (the call-graph projection is total and lossless in both directions) - When
stats.callResolutionis present, it is a faithful census ofsymbols[].calls[]:totalCallsequals the number of call sites,resolvedCallsequals the number with a non-nullresolved, and the fiveunresolvedbuckets sum to the difference. A drift here would report unresolved calls the document does not contain, or hide ones it does - No
symbols[].idand nodependencies[].from/.touses a reserved language token (§3.5) — today that means no id beginsslice:, which would be indistinguishable from a Slice id and would make the Slice-id derivation produceslice:slice:… symbols[].idandcomponents[].idsatisfy the grammars of §3.1 and §4, andsymbols[].namesatisfies the qualified-name grammar of §3.1. Every other route to an id runs a constructor that enforces this; a document read from disk has its ids branded by a single whole-document assertion, and this is where they are actually checkedworkspace.languagesis non-empty, every entry satisfies theLanguageIdgrammar of §3.1, and everysymbols[].languageappears in it. The Document declares which languages it covers, so a Symbol in a language the Document does not list means either an incomplete declaration or a Symbol that does not belong to this scan- Every string the Document orders or identifies by is in Unicode NFC. §1.2 defines the rule, the fields it covers, and the two categories it deliberately excludes
- The Document has the shape
aburi.ir.v1requires: every field the schema lists asrequired, of the declared kind, at every depth. This one is different in kind from the nineteen above — they relate fields to each other, this one establishes that the fields are there to relate. The scope is the schema's requirements rather than "whatever the other invariants happen to read", becausereadIRbrands its resultIRon the strength of this check alone: a narrower check would hand@aburi/diffa Document with nofingerprintand let it fail outside anyone's error handling. When #20 fails it is reported alone — the nineteen are statements about a Document, and a value that fails #20 is not one. The restatement is kept honest by a test that readsschema/aburi.ir.v1.jsonand fails on arequiredentry with no counterpart stats.parsedFilesnever exceedsstats.totalFiles, and whenstats.skippedFilesis present, its length is exactlystats.totalFiles - stats.parsedFilesand no path appears twice. A read-side check: inside Aburi's own scan both sides reduce to the same sum of the same two counts, so the census clause cannot fire on a document it wrote. What it guards is a document arriving from disk or from another generator, whichaburi diffis about to read to decide that an absent Symbol is a loss rather than a deletion — a list short by one entry sends it back to reporting a deletion nobody made. It does not, and cannot, cover a file that parsed successfully and yielded no Symbols: that file is counted inparsedFiles, appears in no array, and satisfies the check. The census clause is conditional on presence, because a document written before the field existed has no way to satisfy it and its absence is itself the answer — the enumeration is unavailable. The ordering clause is unconditional, and has to be: for such a document the subtraction is the only trace of a loss there is, so a reader that reaches for it needs it to mean what it says.parsedFiles > totalFilesotherwise satisfies every invariant here, and a reader taking the difference for a count reads a negative one as "nothing was lost"
An invariant violation is a fatal error, not a warning.
15. Versioning
15.1 $schema URL
- Fixed to
https://aburi.kage1020.com/schema/aburi.ir.v1.json - Backward-compatible field additions: allowed within v1
- Field removal / type change / semantic change: goes to v2. Change
$schematoaburi.ir.v2.json
15.2 Compatibility policy
| Change | Compatibility |
|---|---|
| Adding a required field | breaking |
| Adding an optional field | non-breaking |
| Adding an enum value (only where consumers tolerate unknowns) | non-breaking |
| Removing an enum value | breaking |
| Required → optional | non-breaking |
| Optional → required | breaking |
| Renaming a field | breaking |
| Changing array-ordering rules | breaking |
Because consumers may treat unknown kind values as errors, additions to the kind enum are also treated as breaking. extKind / derivedBy / effects[].id (x- extensions) / via are close to free-form strings, so additions are unrestricted.
15.3 Freezing the core effect vocabulary
The core effect vocabulary of §9.1 is additive-only within version v1. Deletion and semantic change are forbidden. Plugin extensions are fully separated by the x- prefix, so freezing the core does not hinder plugin evolution.
15.4 Deferred to v2
Shapes the schema would have if v1 were not frozen, recorded here so the reasoning is not rediscovered:
- Promote the five Class A fields of §1.1 —
Symbol.component,Symbol.signature,Component.description,SourceRange.startColumn,SourceRange.endColumn— intorequired. Breaking per §15.2, and the breakage is concrete rather than theoretical: every document generated before the promotion becomes invalid, andaburi diffreads a committed IR as its base, so a repository that commits its IR would see a green CI turn red without anyone touching the repository. That every writer in this codebase already satisfies the constraint does not change that.
Whether the promotion is worth doing in v2 at all is open. The §1.1 reader rule already makes an absent key and null indistinguishable to every conforming consumer, so promoting buys stricter validation of third-party producers and nothing else.
Split
stats.skippedFiles[].reason: "unroutable"into the two things it means. Today it covers both "no loaded plugin claims this extension" and "this name holds:or#, so nothing in it can be given a Symbol id". The two want different fixes — a plugin, and a rename — and the skip detail is currently what tells them apart. A distinct value (unnameable, say) would put the distinction where a machine reads it.Not v1:
reasonis a closedenumhere and, by$ref, inaburi.diff.v1.json, so a document carrying a new value is rejected by any validating reader. §15.2 permits enum additions "only where consumers tolerate unknowns", and this is the same condition thekindenum fails — Aburi's own CLI maps the reason through a table that is total over the union by design.Give a Document path a way to spell a backslash. A filename may contain one on any POSIX filesystem, and #10 refuses the character outright because
/is the only separator a Document path has — so such a file is not merely unnameable by a Symbol id, it is unrecordable, and the scan has to drop it fromtotalFilesand report it out of band. An escape (\\for the literal, say) would let it be counted and skipped like any other loss.Not v1: every path in every document written so far would have to be read through the unescaping rule to stay correct, and a reader that does not know the rule turns
a\\b.tsinto a two-segment path — silently, and in the direction that invents a directory. The change is to how paths are read, which is the one kind of change a frozen wire format cannot absorb.What the gap costs downstream, so the v2 case is on the record:
aburi diffseparates a deletion from a loss usingstats.skippedFiles, and a file the format cannot name is in no list at all. A file renamed into such a name between two revisions therefore has its base Symbols read as deletions somebody made.aburi scangates on it in the run that hits it, but two documents compared later carry no trace.
16. Extension Points
Places where Aburi can be extended without forking the core:
| Extension target | Location | Extension form |
|---|---|---|
| Special concepts of a new language | extKind | <namespace>:<kind> |
| Runtime/library-specific effects | effects[].id | x-<plugin>:<action> |
| Additional extraction evidence | derivedBy | free-form string (by convention <plugin>:<reason>) |
| Logical Component boundaries | components[] (via config) | arbitrary |
To avoid breaking compatibility, the design is deliberately asymmetric: fields consumed by the Aburi core are strict enums, while fields extended by plugins are free-form strings.