Language Plugin Interface
Definition of the plugin interface for adding a new language to Aburi's extraction pipeline. One language plugin is responsible for one language id (e.g. ts / py / go) and produces Symbol candidates and AST metadata from source strings.
References:
ir-schema.md— the structure of the Symbols to producefingerprint.md§5 — the input contract for the syntax fingerprintextension-vocab.md— manifest and vocab registrationdrop-list.md— where drops are applied
1. Purpose
Keep the extraction pipeline language-agnostic while accommodating each language's AST shape, naming conventions, and syntactic sugar.
The core (@aburi/core) knows nothing about languages. It receives input normalized into the common Symbol shape and performs effect classification, decoration removal, fingerprint computation, and diff computation.
2. Plugin Responsibilities
2.1 In scope
- Declare the file extensions of its language
- Parse sources and hold an internal AST
- Extract all Symbol candidates from the AST (drop determination is the core's job, but the plugin must provide all the material for it)
- Extract each Symbol's Signature, Decorator, Visibility, SourceRange, qualified name, and derivedBy (language-level)
- Walk each Symbol body and extract Rules and call_expressions
- Produce a normalized AST string per Symbol (the syntax fingerprint input)
- Extract import statements and return them as
ImportEdge[] - Report language-specific file-level / symbol-level drop hints to the core
2.2 Out of scope
- Effect classification (= responsibility of effect plugins)
- Boundary determination for decorators (= responsibility of framework plugins)
- Computing
Symbol.fingerprint.api/.logic(= the core computes these from the normalized IR) - Hashing
Symbol.fingerprint.syntax(= the core hashes the plugin's string output) - Applying config.suppress / config.keep (= the core does this during drop-list evaluation)
- Call resolution (filling in
calls[].resolved) - Building cross-language Dependencies (the core builds them from import edges)
- Generating the Markdown projection
Keeping the responsibilities narrow lets a new language addition focus solely on AST extraction.
3. Lifecycle
1. The registry validates the manifest and loads the plugin
2. plugin.init() is called with ctx (registry/config)
3. For each file matching the language:
a. plugin.parseFile(file) → ParseResult { tree, errors, imports }
b. plugin.extractSymbols(tree, ctx) → SymbolCandidate[]
c. For each SymbolCandidate:
plugin.walkBody(symbol, ctx) → BodyExtraction { rules, calls, returns }
plugin.normalizeAst(symbol) → string (syntax fingerprint input)
d. plugin.releaseTree?(tree) — on every way out of a–c, including a throw
4. After all files, plugin.cleanup?() is calledThe core then performs effect classification, drop-list application, and fingerprint computation.
4. Interface
The actual types are defined in the types package of @aburi/core. This document shows the signatures as a contract.
4.1 LanguagePlugin
interface LanguagePlugin {
manifest: PluginManifest // type: "lang"
// the LanguageId this plugin owns — the prefix on every Symbol id it produces, and
// what core projects into IR.workspace.languages. Constrained to ^[a-z][a-z0-9]*$ by
// aburi.ir.v1, so it is NOT the manifest name ("lang-typescript" is a plugin ref).
languageId: LanguageId // e.g. "ts"
// file matching (extensions only, not globs)
fileExtensions: string[] // e.g. [".ts", ".tsx", ".mts", ".cts"]
// capabilities (§6)
capabilities: LanguageCapabilities
// lifecycle
init(ctx: PluginContext): Promise<void>
cleanup?(): Promise<void>
// extraction
parseFile(file: SourceFile): Promise<ParseResult>
releaseTree?(tree: ParsedTree): void // free the handed-over tree (§8.1); the core
// calls it once, after the last reader below
extractSymbols(tree: ParsedTree, ctx: ExtractionContext): SymbolCandidate[]
walkBody(symbol: SymbolCandidate, ctx: WalkContext): BodyExtraction
normalizeAst(symbol: SymbolCandidate): string
// per-file / per-symbol drop hints (optional)
fileDropPatterns?: string[] // language-specific globs (e.g. ["**/*.d.ts"])
symbolDropHint?(symbol: SymbolCandidate, ctx: ExtractionContext): DropHint | null
}4.2 SourceFile / ParseResult
interface SourceFile {
path: string // workspace-relative POSIX
content: string // UTF-8
}
interface ParseResult {
tree: ParsedTree | null // plugin-internal type (opaque); null → parse produced nothing
errors: ParseError[]
imports: ImportEdge[]
}
interface ParseError {
message: string
line: number // 1-based
column: number // 1-based
recoverable: boolean // false → the core withdraws this file (§7.1)
}
interface ImportEdge {
source: string // verbatim string (e.g. "@billing/domain", "./util")
symbols: string[] | '*' // named-import symbol names, or "*"
line: number
dynamic: boolean // true for import()
namespaceBinding?: string // the local name bound to the whole module, when there is one
}tree is nullable so a plugin can report a parse that produced nothing without fabricating a tree its own type would refuse. It and recoverable: false are companion signals: a plugin that has no tree is expected to say why in errors[] too, and §7.1 reads the two as one condition, so a plugin that sets only one still gets the withdrawal it asked for.
4.3 SymbolCandidate
interface SymbolCandidate {
// precursor of an IR Symbol; the full set before drop determination
id: SymbolId // <language>:<file>#<qname>, from makeSymbolId
kind: SymbolKind // ir-schema §5.1 enum
extKind: string | null // §5.2 (chosen from what the plugin itself declared)
name: string // qualified name
visibility: Visibility
decorators: Decorator[]
signature: Signature | null
source: WrittenSourceRange // SourceRange with both columns required (see below)
derivedBy: string[] // language-level evidence (e.g. ["export-keyword"])
// internal handles passed to walkBody / normalizeAst
bodyNode: OpaqueAstNode | null
mergedDeclarations?: MergedDeclaration[] // §4.3.1, absent when one declaration wrote it
fullNode: OpaqueAstNode // signature + body
}id is a SymbolId, not a string: the type is nominal (ir-schema.md §3.5), so a plugin cannot hand core an id it assembled by concatenation. Build it with makeSymbolId from @aburi/core, which enforces the §3.1 grammar — a lowercase-ASCII language token that is not reserved, a POSIX workspace-relative path, and an identifier-like qualified name — and throws a coded CoreError otherwise. trySymbolId is the non-throwing variant, for a plugin that assembles speculative ids and expects some of them not to be buildable.
The path segment of id must be the path of the file being extracted — ctx.file.path, the same value the candidate's source.file carries. The grammar cannot enforce this: another file's path is a perfectly legal path, so makeSymbolId builds the id and the fault only shows as a Symbol filed under a file that never declared it. A plugin re-exporting or generating code names the declaring file in source.file and the declaring file alone, rather than the file it happened to read. The scan checks this per file and withdraws the file that broke it (§7.2).
A brand can be asserted rather than constructed, so the type is a contract, not a lock. Do not take that route: a plugin whose qualified names the §3.2 grammar cannot express — Ruby's save! and valid? are the standing example — must widen the grammar, not cast around it. The call-graph resolver and the LSP enrichment tier build candidate ids through trySymbolId and treat a refusal as "no such callee", so an id the constructor would have rejected resolves against nothing while looking like an ordinary miss. assertIRIntegrity catches it at the end of the scan (§14 invariant #17) and names the offending id, so it surfaces as a failure rather than as silently thinner output — but the diagnostic bucket in call-resolution.md §8.1 has no way to say "the grammar refused this candidate", so the reason will not appear in stats.callResolution.
source is a WrittenSourceRange, not the IR's SourceRange: startColumn and endColumn are required on the write side, carrying null when the plugin cannot determine a column. This is the Class A rule of ir-schema.md §1.1 expressed as a type. The narrowing matters because the canonical serializer drops properties whose value is undefined — a plugin that left the keys off would type-check against the wider SourceRange and then emit a document missing them, with nothing between the two to notice. The IR's own SourceRange stays optional so that a document written before the rule remains representable when it is read back off disk.
A plugin that has real column information may write it; nothing forbids that. The in-tree TypeScript plugin deliberately does not, so that every column in an Aburi IR comes from textDocument/documentSymbol (lsp-enrichment.md §4.2) and one convention about what a column counts. Be aware that a published column is not durable: the enrichment pass overwrites both keys on every Symbol it matches (§5), so a plugin-written column survives only where the LSP tier produced nothing — which is where it is least likely to be checked.
When the plugin chooses an extKind from its own declarations, the chosen value must fall under manifest.provides.extKinds or extKindPrefixes. The registry detects violations at startup.
4.3.1 One entity, several declarations
A Symbol id names an entity, not a declaration, and most languages let one entity be written more than once: a getter beside its setter, an overload beside its implementation, an interface reopened, a namespace augmenting the class above it, a partial class. A plugin emits one SymbolCandidate for all of them. Two candidates sharing an id costs the file they were written in: ir-schema.md §14 invariant #1 is decided per file, inside the boundary of §7.2, so the file is withdrawn and named and the rest of the workspace is scanned. It is still a file this plugin could have extracted, so folding is the plugin's job either way.
Which declaration leads is the plugin's to decide and to say in its own tests; what the interface fixes is where the rest goes. bodyNode / fullNode are the leading declaration's, and mergedDeclarations holds the others in source order, so that walkBody and normalizeAst describe the entity rather than whichever declaration was written first — a set password(v) that hashes the value has effects, and dropping it because a getter was written above it loses them with nothing to say so.
interface MergedDeclaration {
bodyNode: OpaqueAstNode | null
fullNode: OpaqueAstNode
}Each entry carries both nodes, for the reason the Symbol itself does: a declaration with no body — an enum, a type alias, a namespace whose statements are their own Symbols — is described by its fullNode, which is where normalizeAst already looks when a Symbol has no body. An entry holding only a body makes a reopened enum E {} fingerprint as though the second declaration had never been written, so adding, editing or deleting it changes nothing. Only the bodies belong in the body walk: a merged namespace walked here would be walked a second time through the member Symbols its statements already produce.
Everything list-shaped on the Symbol joins the same way — derivedBy and decorators both. Dropping a merged declaration's decorators is not cosmetic: an interface P {} written above an @Controller() class P {} is legal, so the leading declaration is the one carrying none, and a lost boundary decorator moves the Symbol back inside the drop list (drop-list.md §4.1).
The field is optional, absent and never empty, so a Symbol with one declaration — the ordinary case — does not carry the key at all. A consumer that reads only bodyNode is correct on those, and every path that predates the field still is; a consumer that describes the Symbol from its declarations reads both.
Nothing in the core checks that a plugin folded its declarations — the invariant catches the omission, at the file that wrote it. derivedBy is where a plugin says a fold happened; the in-tree TypeScript plugin writes declaration-merged.
4.4 BodyExtraction
interface BodyExtraction {
rules: Rule[] // ir-schema §8
calls: CallCandidate[] // raw calls before effect classification
}
interface CallCandidate {
target: string // string representation of the callee (before whitespace normalization)
line: number
argumentCount: number // arguments only — comments between them do not count
inAwait: boolean // true when under an await
inNew: boolean // true when a new expression
literalArgs: (string | null)[] // literal value of each argument (null if not a literal)
dynamicReceiver?: boolean // optional; true when the receiver was an expression
}walkBody must not emit trivial returns as rules (drop-list §5.3-5.5). For call-only returns such as return foo(), the call goes into CallCandidate but not into a Rule.
Argument-list contract
argumentCount is the number of arguments, and literalArgs[i] is the literal value of the i-th one. Comments are not arguments. Most grammars carry them as extras and hang them wherever they were written, including between the parentheses of a call, so a plugin that maps the argument node's children straight across reports f(\n x, // why\n) as a two-argument call and gives literalArgs a slot that belongs to no argument — shifting every later one, so literalArgs[0] may describe a comment rather than the first argument.
Effect plugins read both as a signature (effect-plugin.md §5.4), so the miscount lands as a call attributed to the wrong API, or as a real effect that never reaches the IR. Filter the comment nodes out; the count and the array are then the same length and the same order as the source's arguments.
Normalized-callee contract
target is non-empty, and every .-separated segment of it is non-empty. A leading, trailing, or adjacent dot (".create", "prisma.user.", "prisma..create") is a contract violation, not an input an effect plugin has to tolerate: "prisma..create" splits into three segments and would match a delegate-call shape that never existed in the source. The same holds for the ImportEdge of §4.2 — source is a normalized, non-empty module specifier.
Whitespace is not part of the rule: a segment or specifier is rejected when it is empty, not when it is blank. "prisma. .create" satisfies the contract as written — plugins are free to be stricter, but the shared guards are not.
Consumers enforce this rather than work around it. assertNonEmptySegments, assertImportEdgeSource, assertImportBinding and hasMatchingImport in @aburi/plugin-registry/plugin-input are the shared guards. They throw, and a violation propagates rather than degrading to an unclassified call (effect-plugin.md §10, EP3a), so an unnormalized callee surfaces as a failed scan instead of a silently miscategorized effect.
Because the guards throw, the producing side has to hold the line rather than pass the problem on. A source construct that would yield an empty specifier is legal to write — import x from "" parses, and tsc rejects it at resolution as TS2307 — so the plugin emits no edge for it and records a recoverable ParseError at the specifier's position. The file keeps its Symbols (a file is withdrawn when its parse returns no tree, which this is not), the diagnostic carries a line to fix, and the guard stays a signal that something upstream is genuinely broken.
A receiver that is not a name
A receiver the normalizer does not model never contributes its source text to target. Such a receiver — an array, object, string, number or template literal, new C(), an await, a binary expression, a function or arrow expression (an IIFE) — contributes the reserved segment <computed> (the same COMPUTED_TARGET_SEGMENT a bracket access uses, below) and the call carries dynamicReceiver: [...names].sort() is <computed>.sort, and an IIFE is <computed>. Its text would break the contract above ([...names] holds two empty segments, (.5) a leading dot) or put a function body, indentation included, into the IR.
A type-level wrapper — svc!, x as Foo, x satisfies Foo, <Foo>x — still answers with its own text around a name, so svc!.save() is svc!.save and is not dynamic. Around anything else it answers what the wrapped expression does (([...a] as T).m() is <computed>.m, getRepo()!.save() is the dynamic getRepo.save), and a text that would still hold an empty segment ((x as [...T]).m()) or a line break (a type literal written over several lines) answers <computed>: a target is read as a name, and reformatting must not change it. The keyword callee of a dynamic import("./m") is import, and import.meta and new.target are their own spellings.
A bracket access in a callee
prisma["user"].create(data) and prisma.user.create(data) are one call written two ways, and the target says so: a string-literal index whose value is a qualified-name segment (ir-schema.md §3.1) folds into the target as that segment, a private name aside (below). The literal is decoded rather than unquoted, so prisma["us\u0065r"] is prisma.user too, and it is refused when the parser guessed at any of it — a partial read (obj["a\u12b"]) and a whole literal standing beside an ERROR (prisma["user" "audit"], a missing operator) both name a property the source does not spell. That is the pair of guards a class member's written name already answers with (§9.1, LP8m).
Every other index names no segment: an identifier (prisma[model]), a number (items[0]), a substituting template, a string the grammar has no segment for (obj["a-b"], obj["a.b"], ""). A string that opens with # names no segment either, for the opposite reason: the grammar has the segment #v, but it names the #-private member (LP8o), and obj["#v"] is the public property with those characters. Core's isQnameSegment refuses #v unless asked for a private name, and a decoded string never asks. The bracket contributes the reserved segment <computed> instead — the exported COMPUTED_TARGET_SEGMENT, so both sides of the boundary spell it from one place — and the call carries dynamicReceiver, because a property nothing names is a property reached by evaluating an expression.
Where the brackets sit is not part of the question. The index of handlers["run"]() names the function being called exactly as the one in prisma["user"].create() names the receiver, so the terminal slot folds by the same rule and handlers[name]() answers handlers.<computed>. The flag then reports the callee rather than a receiver — which is what call-resolution.md §8.1's dynamic bucket wants either way: nothing in the target is a name a tier could look up.
Dropping the index is the one answer that is not available. prisma["user"].create() read as prisma.create is not a lossy spelling of the call, it is a different call: it never appears in the program, it is spelled exactly like an ordinary two-segment method call, and a consumer that counts segments — effects-prisma needs three for a delegate call — loses the db.write with nothing anywhere to say that it did. Recording no call at all would at least be honest, and it costs the call site its diagnostic, its arguments and any effect on it, which is the same loss paid twice.
A consumer reading a fixed position meets the sentinel where it expected a name. It names none, and a consumer that treats it as one is reading evidence out of a segment that carries none — queues[id].upsert(job) has a delegate call's three segments and is a queue. The rule is the plugin's own: effects-prisma requires the receiver to name a client binding before it credits a computed model, and effects-trpc caps a dynamicReceiver call at medium.
<computed> is unresolvable by construction: the segment grammar of ir-schema.md §3.1 admits no <, so every candidate id built through such a target is refused by trySymbolId, and no Symbol name a component-scope or workspace-scope lookup compares against can hold one (call-resolution.md §4.5, §4.6). (<default> is reserved ahead of that pattern rather than by it, so the safety is this segment's spelling, not the angle brackets.) An effect plugin claiming the call is the one path that leaves that bucket with nothing to say: a classified call never reaches calls[] (ir-schema.md §9.3) and is counted by no bucket, so what records the computed segment there is effects[].target and the tier the plugin chose.
This is ir-schema.md §3.2's computed-member row read from the other side, and the two do not disagree. A qualified name is a resolution target — inventing a segment there mints an id, and an id that names nothing is worse than no Symbol. A call target is a reading of what the source calls, which a missing segment falsifies, and which resolves against nothing either way.
dynamicReceiver
Set it to true when the callee's receiver was an expression rather than a name — getRepo().save(), items[0].save() (items.<computed>.save), (a ?? b).save() — and when the callee itself is one, which brackets are the way to write (handlers[name](), §4.4). Leave it absent otherwise; absent means false, so existing plugins stay valid without a change.
Normalization is lossy here in a way only the plugin can repair: getRepo().save() collapses to the target getRepo.save, which is spelled exactly like a genuine Class.method qname. Without the flag, call-resolution.md §8.1 would have to file every such call under no-match and send reviewers hunting for a typo that does not exist; with it, the call is correctly reported as dynamic.
Do not set it for this.save() / super.save() — §4.7 already keeps those unresolved through a separate rule, and the resolver buckets them itself. Be conservative: a receiver shape the plugin does not model (a non-null assertion, a type assertion) is not evidence of dynamic dispatch, and over-reporting would make the bucket useless. The flag never affects which calls resolve — it only decides which diagnostic bucket an already-unresolved call lands in.
4.5 ExtractionContext / WalkContext / FrameworkClassifyContext
interface ExtractionContext {
file: SourceFile
registry: VocabRegistry // ext-vocab §7
config: AburiConfig
}
interface WalkContext extends ExtractionContext {
symbol: SymbolCandidate
}
interface FrameworkClassifyContext extends ExtractionContext {
imports: readonly ImportEdge[] // the same edges parseFile produced for the file
}FrameworkClassifyContext is what a framework plugin's classifySymbol receives (§5.2). A plugin with no use for the edges may declare the parameter as the supertype ExtractionContext and still satisfy the interface.
The array is the live one, not a copy — the same instance the pipeline reports as the file's imports and hands to call resolution — hence readonly. A plugin may memoize per file on its identity; it must not mutate it.
4.6 DropHint
interface DropHint {
reason: string // goes directly into the IR Symbol.dropReason
category: 'B' | 'C' // category per drop-list §2
}Example: for TypeScript's export type X = Y, return { reason: "type alias", category: "B" }.
4.7 PluginContext
interface PluginContext {
registry: VocabRegistry
config: AburiConfig
workspaceRoot: string // absolute path (used by the plugin to initialize its parser)
log: Logger
}5. Cooperation with Other Plugins
5.1 Cooperation with effect plugins
The language plugin's job ends at returning CallCandidate[]. The core queries the effect plugins in order for each call, and the result of the first plugin to return an EffectClassification is adopted.
- A call for which every effect plugin returned null → stays in
Symbol.calls[](target/line/resolved=null) - A call classified by some effect plugin → goes into
Symbol.effects[]and not intoSymbol.calls[](ir-schema §9.3)
See effect-plugin.md for the detailed interface.
5.2 Cooperation with framework plugins
The language plugin extracts each Decorator up to its raw string, arguments, name, and qualifier (the receiver it was written through, ir-schema.md §6). The boundary flag is filled in by the core querying the framework plugin.
5.2.1 First-match-wins among framework plugins
When multiple framework plugins are enabled in the config, first-match-wins in config order, just like effect plugins (same convention as effect-plugin.md §5.1):
- Each SymbolCandidate is passed to the framework plugins'
classifySymbolin config order - The result of the first plugin to return non-null is adopted (extKind / boundary adjustments, etc.)
- Subsequent plugins are skipped
This avoids ambiguous states such as "the same class is recognized as both a NestJS Controller and a Custom Framework Controller". If a project has conflicts, priority is controlled via config order. The ad-hoc plugins frameworkHints stands for come after every plugin in frameworks (config.md §8.3.2).
A framework plugin receives the following inputs:
- the
SymbolCandidate(including decorators) - the file's
ImportEdge[], onFrameworkClassifyContext(§4.5) - the framework declaration of the owning component
Against these it may:
- override
decorator.boundary - fill in
Symbol.extKind(e.g.framework:nestjs:controller) - provide a Category B drop exclusion hint (e.g. a class carrying only
@Modulemust not be treated as a pure DTO) - drop the Symbol through
symbolDropHint(drop-list.md§6.3). UnlikeclassifySymbol, every framework plugin is asked, whichever of them won, and the first non-null answer is the drop
The detailed interface is deferred to a future framework-plugin.md (this document only reserves the contract surface).
5.2.2 Matching a decorator against the import edges
The language plugin reports a decorator under the identifier the source wrote. That identifier is not reliable evidence on its own, in either direction: import { Controller as Ctrl } writes a framework boundary under a name no table holds, and a @Controller from a competing library writes a foreign name that every table holds. A plugin that matches names against its vocabulary resolves the identifier through ctx.imports first:
| What the edges say about the name | Match against | Confidence |
|---|---|---|
| imported from a module the plugin owns | the imported name | high |
| imported from any other module | the imported name | medium |
| no edge binds it | the written name | high |
The middle row downgrades rather than refuses. Re-exporting a framework's vocabulary through a project-local barrel is ordinary practice, and a barrel reached through a build-tool path alias (@app/common) is indistinguishable from a foreign package without reading the build config — so refusing would take the boundary off a whole project's worth of Symbols to close a narrower false positive. medium is the confidence criterion for an identifier match (ir-schema.md §5.4), which is exactly what is left when provenance is unknown.
Two consequences follow for the plugin's outputs. SymbolClassification.decoratorBoundaries is keyed on the decorator as the source wrote it, receiver included — Controller for @Controller(), nest.Controller for @nest.Controller() — because that is the key the core rebuilds from Decorator.qualifier and Decorator.name when it folds the result back in. The leaf alone is not a key: two decorators on one Symbol can share it and still resolve differently (@Ctrl() under import { Controller as Ctrl } classifies, while @x.Ctrl() canonicalises to Ctrl and matches nothing), and a shared key would flag both. derivedBy carries the imported name, because it is a closed vocabulary that diffs and filters read, and renaming an import changes nothing about what the decorator does.
A qualified decorator (@nest.Controller()) is read the same way, one column over: Decorator.qualifier carries the receiver, and the lookup is on the receiver rather than on the leaf. Only the receiver's first dot-separated segment can name a binding — a.b in @a.b.C() reaches scope as a — so that segment is what the plugin looks up, and the name matched against the vocabulary is the leaf as written, because a namespace import renames nothing. The same three rows then apply to the module the receiver names.
Which edges bind a receiver. Both kinds, and a plugin that reads only one leaves the table out of order. import * as nest from "m" arrives as symbols: "*" with namespaceBinding: "nest", and import nest from "m" arrives as symbols: ["nest"] with no namespaceBinding — two spellings of the same disclosure, one in each index. The last row of the table therefore means no edge binds the receiver, not no namespace edge binds it.
A qualified decorator must not fall back to the named-import index for its leaf. The leaf is a property of a module object, not an identifier in the file's scope, so a Controller imported by name from somewhere else says nothing about @nest.Controller(), and reading it would attribute the decorator to a module it was never written through. The receiver is the opposite case, which is why it is looked up in both indexes: nest really is an identifier in scope.
Both halves are necessary for the table to be ordered by how much the file disclosed. A plugin that drops the qualifier sees only the leaf, and a namespace import from a competing library then lands in the last row — trusted further than the named import of the very same decorator. A plugin that keeps the qualifier but indexes only one of the two binding forms gets the same result one spelling over.
Two shapes reach the edge list without binding anything in local scope, and a plugin matching names should know both. A re-export (export { X } from './y') names a symbol the file republishes rather than uses — and its aliased form arrives as the source-side name alone, since the language plugin composes " as " on imports but not on re-exports. Because re-exports do not bind, a name can appear on two edges in a file that compiles, so a plugin needs a duplicate rule and should say which of its outcomes are order-independent.
5.3 Extraction order
plugin.parseFile() [lang]
↓
plugin.extractSymbols() [lang]
↓
framework plugin classifySymbol() [framework]
↓
plugin.walkBody() [lang]
↓
effect plugin classify() for each call [effects]
↓
plugin.normalizeAst() [lang]
↓
core applies the drop list / computes fingerprints
↓
plugin.releaseTree() [lang, called by the core — §8.1]
↓
core assembles the IR6. Capabilities
LanguageCapabilities is a flag set through which the plugin declares "what is expressible in my language". The core and other plugins use it for branching.
interface LanguageCapabilities {
hasDecorators: boolean
hasGenerics: boolean
hasAsync: boolean
hasMacros: boolean
hasPatternMatching: boolean
hasAbstractTypes: boolean // abstract class / trait / interface
hasModules: boolean // ES module / Python module / Go package
hasNamespaces: boolean // TS namespace / C# namespace
hasTypeParameters: boolean
hasExplicitVisibility: boolean // public/private keywords
hasJsDoc: boolean // JSDoc / docstrings, etc.
}For the runtime resource budget (wasmHeapPerWorkerMB), the source-of-truth is the capabilities of the plugin manifest, not the runtime interface. The CLI reads the manifest to control concurrency, so it is not duplicated at runtime (see §8.1 / cli-spec.md §11).
If a framework plugin requires decorator-based extraction from a language with hasDecorators: false, the core aborts with an error at startup.
7. Error Handling
7.1 Parse errors
recoverable: true→ the core proceeds to Symbol extraction (tree-sitter is normally recoverable)recoverable: false→ the core withdraws the file: no Symbols reach the IR, it is recorded inScanResult.skippedwithreason: "parse-failed"and a detail quoting the error's message and position, it is excluded fromstats.parsedFileswhile still counting towardstats.totalFiles, and the core logs a warning. Read as exactlyfalse, not as falsiness: plugins arrive as plain JavaScript, and a plugin that omits the key gets the behaviour it had before the field was read at all rather than having every file it warned about withdrawn.- A
treeofnullwithdraws the file on the same terms, and is expected to carry arecoverable: falseerror beside it (§4.2). The two are read as one condition, so a plugin that sets only one still gets what it asked for — and a plugin that built a usable tree and then decided the file must not be used (a wrong dialect, a generated blob) does not have to discard the tree to say so. - Its parse errors are still reported on
ScanResult.parseErrors, for the reason §7.1.2 gives: they are diagnostic rather than IR, and here they are the entire account of why the file went. - Its import edges are kept — the one place this differs from a file abandoned on its
parseTimeoutMsbudget, which is being withdrawn deliberately. A file whose contents could not be used still told us truthfully what it imports. Nothing consumes them yet: call resolution looks the list up by the file a Symbol came from, and a withdrawn file has none, so the entry waits for the dependency-extraction pass. aburi scanstays at exit0. An unparseable file is a property of the source, like an over-size or timed-out one; no skip reason moves the code by being that reason, andextraction-failedalone moves it by being one (§7.2, cli-spec.md §5.4). What does move it regardless of reason is how much was lost: a scan that parsed nothing exits3, as does one that fell below aminParsedFileRatiothe workspace set (cli-spec.md §5.7). Refuse every file in a workspace and the run is not green.- warning stderr:
Skipped <file>: parse reported a non-recoverable error at <line>:<column> — <message>. With no tree and no such error, the first recoverable one is quoted instead —Skipped <file>: the language plugin returned no tree; first error at <line>:<column> — <message>— because a withdrawn file is excluded from the CLI's recoverable-error count and this line is then the only place its errors can be read.Skipped <file>: the language plugin returned no treewhen there were none at all.
7.1.1 Large-file skip
Files whose size exceeds config.maxFileSizeBytes (default: 2 * 1024 * 1024 = 2MB) are skipped without parsing.
- Normal code does not exceed 2MB (only generated bundles / minified files do)
- Large files exhaust the WASM heap and make parse time explode
- Skipped files are returned on
ScanResult.skippedwithreason: "over-size"and named in the Document atstats.skippedFiles[](ir-schema.md §2), so a lateraburi diffcan tell a file that was never read from one whose API was deleted - warning stderr:
Skipped <file>: <size>MB exceeds maxFileSizeBytes (2MB). Override with config.maxFileSizeBytes.
7.1.2 Timeout
If the total of parse + extractSymbols + walkBody for one file exceeds config.parseTimeoutMs (default: 5000 = 5 seconds; the config schema's minimum is 100 and it has no maximum), abort, skip that file, and warn. This prevents a broken grammar or pathological source (deep nesting, etc.) from stalling the whole run.
The budget is cooperative, for the reason effect-plugin.md §5.1.1 gives for the classify budget: extractSymbols and walkBody are synchronous plugin calls, and nothing can interrupt one that has already started. It is read at the three points that bound the work still to come — after parseFile, after extractSymbols, and before each candidate's walkBody — so what it guarantees is that an over-budget file is handed no further work. A file costs at most its budget plus one stage, and one enormous candidate can still overrun by however long that candidate takes. A hang inside a single call is not something a wall-clock budget can catch at all.
An aborted file contributes nothing to the IR: no Symbols, no import edges, and no stats.effectClassifyTimeouts entries accumulated from the candidates it did finish. Keeping whichever Symbols it produced before the budget ran out would make the Document depend on how fast the machine was that day, so the outcome is binary per file. It is recorded in ScanResult.skipped with reason: "parse-timeout", repeated on ScanResult.parseTimeouts with the budget and the elapsed, and excluded from stats.parsedFiles while still counting toward stats.totalFiles.
Its parse errors are still reported. They are diagnostic rather than IR, and they are what a slow file most needs to keep: backtracking over malformed input is a common reason for a slow parse, so a run that swallowed them would tell the reader to raise parseTimeoutMs when the fix is the syntax. A file that is both broken and slow appears in parseErrors and in parseTimeouts — but never in both parseTimeouts and a §7.1 withdrawal. The withdrawal is decided before the first deadline reading, and the two would compete for the one skipped entry the file gets: a plugin's outright refusal reported as a file that was merely slow sends the reader to raise a budget that was never the problem.
- warning stderr:
Skipped <file>: extraction reached <elapsed>ms, exceeding parseTimeoutMs (<budget>ms). Override with config.parseTimeoutMs.
A file being skipped on wall clock does mean the IR can differ between a fast machine and a slow one, at file granularity. That is inherent in asking for a time budget; a run that wants reproducibility across machines sets parseTimeoutMs high enough that nothing reaches it.
7.2 Extraction exceptions
- If any plugin call for a file throws —
parseFile,extractSymbols,symbolDropHint,walkBody,normalizeAst, a frameworkclassifySymbol, or an effectclassify— the core withdraws that file and keeps going. One file's bug does not halt IR generation. - A file whose Symbols cannot enter the Document is withdrawn on the same terms, though nothing threw. Today that is ir-schema.md invariant #1, read off the file being extracted: two of its own Symbols under one id, or one whose id names a path that is not this file's (§4.3). Both are the fault of the file being scanned, so that is the file that goes. The check runs per file, before any of the file's output is accumulated, because that is the last point at which the file can still be withdrawn and named —
assertIRIntegritysees the assembled Document and can only refuse the whole of it, which is what used to happen. Which of two Symbols to keep is not the core's to decide — the plugin reported nothing that separates them — so the file goes whole, withcode: "duplicate-symbol-id"on itsScanResult.extractionFailuresentry and the collision named indetail. The document-wide check stays where it is, as the backstop. - The withdrawn file is named in
ScanResult.skippedwithreason: "extraction-failed", and the thrown message is kept beside it inScanResult.extractionFailures. The core logs a warning per file;aburi scanexits3when the list is non-empty, so a run that dropped a file is not green. - A file withdrawn because a plugin threw loses its recoverable parse errors, unlike one abandoned for its
parseTimeoutMsbudget: the pipeline result never materialized, so the thrown message is the diagnostic in their place. A file withdrawn for a duplicate id keeps them, on the same reasoning read the other way — its extraction ran to a result, so those errors are observations the parse really made about the file, and dropping them would hide a genuine parse fault behind an id fault that may well be its consequence. - Some are not absorbed. An error whose code names a fault in the plugin set rather than in the file propagates and ends the run, because it repeats for every file and withdrawing them one at a time would report the workspace as broken instead of the plugin. Today that is
scan-plugin-misconfigured(an effect plugin returning a Promise fromclassify, a language plugin emitting Symbol ids with no language prefix),invalid-language-id(the prefix is present but is not a legalLanguageId, which comes from the plugin's ownlanguageId), andvocab-undeclared(an id the emitting plugin's manifest does not claim —effect-plugin.mdEP1). Everything else describes the file —anonymous-symbol-id-attemptedfrom a qualified name the grammar refuses,non-posix-pathfrom where the file lives — and is absorbed. - A plugin-wide bug that carries none of those codes still presents as one failure per file rather than one crash. That is the intended shape — every file named, the messages identical, the count the whole workspace — but it is a weaker diagnostic than a code that says outright what is wrong.
- The behaviour above is unconditional: there is no "stop at the first extraction exception" mode. (
config.strictis about something else: whether an undeclared value ends the run, which it does throughvocab-undeclaredabove.)
7.3 Manifest violations
- If a plugin puts an
extKindnot declared in its manifest into a SymbolCandidate → extraction-time error (drop-list §6.3 / extension-vocab §6.3) - In
--discovermode this is downgraded to a warning (extension-vocab §11.5)
7.4 Syntax the language plugin does not support
- Example: the TS plugin encounters an unknown syntax element (a future TS language extension) → skip without creating a SymbolCandidate, debug log
- This is not an error (to allow incremental support of new syntax)
8. Parser Implementation Options
A language plugin may choose its parser freely, provided it satisfies the following:
- Position information (line/column) is available per node
- Recovery from partial parse failures is possible (recommended)
- Node kinds are distinguishable (statement / expression / declaration classification)
- Access to a raw AST that does not desugar the language's syntactic sugar (e.g. does not collapse
async functionintofunction+ flag)
Representative options:
| Parser | Applies to | Notes |
|---|---|---|
| tree-sitter (WASM) | multi-language | rich grammars, recoverable, first choice for official Aburi language plugins |
| tree-sitter (native) | multi-language | faster than WASM but requires a node-gyp build |
| oxc-parser | TS/JS | faster than tree-sitter, TS-only |
| ast-grep | multi-language | tree-sitter based, easy pattern authoring |
| swc | TS/JS | written in Rust, TS-only |
| ruff (internal AST) | Python | written in Rust, limited public AST API |
| go/parser | Go | standard library |
| syn | Rust | proc-macro only, heavy for standalone CLI use |
The official @aburi/lang-typescript plugin adopts tree-sitter WASM for its initial implementation (proven in a PoC; zero-build on Windows is an advantage). Replacement with oxc-parser is under consideration for a later version.
8.1 Memory-management convention for WASM parsers
WASM parsers such as web-tree-sitter hold a WASM heap separate from the Node heap; there is a known issue where failing to explicitly free parser instances leads to RangeError: WebAssembly.Memory() crashes after parsing thousands of files.
Each plugin must follow these conventions:
- Release the parser and the tree, each on the side that owns it
- Create the parser inside
parseFile()and callparser.delete()after obtaining the result. It never leaves the function, so the plugin frees it. - The tree is handed over. The plugin implements
releaseTree(tree)and the core calls it once per non-null tree, after the last ofextractSymbols()/walkBody()/normalizeAst()has read it — including when one of those threw, when the file was withdrawn, and when it ran out of parse budget. A plugin that deleted the tree on its own way out ofparseFile()would be handing the core a dead handle. - The exception is a
parseFile()that fails after parsing: the caller never receives the handle there, so the plugin releases the tree itself before propagating. - Node references taken out of the tree (
SymbolCandidate.bodyNode,fullNode) are valid for as long as the tree is, which is the file's pipeline run rather than the scope ofparseFile(). None of them may outlive the tree: what the pipeline returns is the strings and ranges read out of the nodes, never the nodes. - A
releaseTreethat throws is recorded onScanResult.treeReleaseFailuresand does not fail the file, whose Symbols are already in the IR. AreleaseTreethat is declared as something other than a function is recorded there too, saying so — it is a contract violation rather than a parser fault, and the two are not described in the same words.
- Create the parser inside
- WASM heap budget under parallel execution
capabilities.wasmHeapPerWorkerMBin the plugin manifest (range: 16–4096 MiB, implicit default 256 MiB when undeclared) is the source-of-truth- The core caps
--concurrencyatmin(specified value, floor(availableMemoryMB / wasmHeapPerWorkerMB)) - When multiple lang plugins coexist in the same run, the maximum of each plugin's declared value is used (sized for the hungriest lang)
- Reservation for a native-binding fallback
- In a future release, switching to native bindings (e.g. the
tree-sitterNode bindings) may be added via flags such ascapabilities.preferNative(see the roadmap) - Currently only WASM is implemented; no native fallback is provided
- In a future release, switching to native bindings (e.g. the
8.2 Cost convention for WASM node access
The same heap boundary has a cost consequence that is easy to miss, because the JS surface hides it. A getter like node.children or node.namedChildren is not a field read: it unmarshals every child across the WASM boundary into a fresh JS object, and the array it returns is cached on that JS wrapper only — the next node.parent hands back a new wrapper and pays for the list again.
So a per-node question must be asked of the node, never of its container:
- Ask the node:
previousSibling,previousNamedSibling,nextSibling,childForFieldName,childrenForFieldName. Each crosses the boundary once per node it returns, and a backwards walk over a run of siblings stops as soon as the run ends.childrenForFieldNameis bounded by the node's own children — a handful for a declaration — and unmarshals only the ones the grammar tagged, so it is a question about the node even though it returns a list. - Do not ask the container: reading the parent's whole child list to find the node's own index in it. It answers the same question, but a top-level declaration's parent is the entire file. Doing it once per declaration makes a file of N declarations cost O(N²) — the shape a generated API client or a Prisma type file has, comfortably inside
maxFileSizeBytesand minutes long to extract.
Reading the container is right when the container is what the question is about: every member of a class body, every argument of a call. It is the per-node use that has to be avoided.
9. Verifiable Properties (Test Criteria)
Every language plugin must pass the following tests.
9.1 Structural extraction
| ID | Input | Expected |
|---|---|---|
| LP1 | top-level function | SymbolCandidate.kind = "function", name = the function name |
| LP2 | class | SymbolCandidate.kind = "class", name = the class name |
| LP3 | class method | SymbolCandidate.kind = "method", name = Class.method |
| LP4 | class static method, or an export of a namespace merged into the class (LP8n) | name = Class::method |
| LP5 | interface (TS/Java/Go) | SymbolCandidate.kind = "interface" |
| LP6 | default export of anonymous function | name = <default> |
| LP6a | a default export written apart from the declaration it names — const Page = () => … followed by export default Page | the declaration's own SymbolCandidate, visibility = "public" and derivedBy carrying export-default, exactly as the wrapped spelling (export default function Page()) reports. The two are one boundary and the frameworks that read export-default to find a page or a component must not depend on which was written — which is also why the value is read through LP7a's wrappers, export default Page satisfies NextPage being an ordinary spelling of the same export. What the reading stops at is what is not a reference to a declaration: a call (withAuth(Page), by LP7b), a value the module builds ({ Page }), a member of one (Routes.Page), and an identifier that names an import, which declares nothing here to reach. A declaration reached by name is reached by a bare name: a class member called Page (Shell.Page, Shell::Page) and a namespaced one (Routes.Page) carry a separator no bare identifier can spell, and a call Symbol (LP20g), whose qname is one identifier-legal segment and so has no separator to rely on, is refused by kind. export { Page as default } is an export clause, and no clause spelling — export { Page } included — is read for visibility yet; covering only the default one would make the plugin's answer depend on the clause's contents |
| LP6b | a declaration carrying the export keyword, of any kind — export interface I {} beside export const x = 1 | visibility = "public" and derivedBy carrying export-keyword, for every kind alike. Whether a declaration was exported is a question about the statement it was written as, so the answer cannot depend on which declaration follows the keyword — and for four kinds it did: the interface, type alias, enum and namespace builders wrote a one-token derivedBy naming the kind and stopped there, where visibility was right throughout. The cost was not a missing field but a spelling-dependent answer: a consumer asking a Symbol "was this exported?" was told so for the const and told nothing at all for the interface beside it. derivedBy is the closed vocabulary diffs and filters read (§4.3, ir-schema.md §5.5) and LP6a already puts exportedness in it — a framework plugin finds a page by reading export-default off a Symbol — so the token is evidence about the declaration, not a note about how the module was written. One reader answers it for every kind, for LP7a's reason: the node it is handed differs per kind (the module for a namespace, the enclosing lexical_declaration or variable_declaration for a variable, the declaration itself for the rest), and a second reader is where the kinds start to disagree again. It reaches the statement through the declare wrapper, so export declare interface I {} answers as LP36 has it. Within one statement export default replaces the keyword rather than joining it — the statement cannot be written with both, and LP6a's token is the one a framework reads; across two statements they join, LP6a's detached spelling carrying export-keyword and export-default alike, so exclusivity is a rule about a statement and never about a Symbol. Illegal source the grammar still accepts answers rather than crashing: export default const x = 1 reports the default export, as the same two keywords on a class do. A declaration written inside an exported namespace is not exported by it, and one written export inside a plain namespace is: the keyword is read off the declaration's own statement, which is what visibility answers too. No export clause spelling is read here either, export { I } included, for the reason LP6a gives. Where the two answers can come apart is a Symbol several declarations wrote, and only in source the language rejects: §4.3.1 gives visibility from the leading declaration and derivedBy from the union, so interface I {} reopened as export interface I {} (TS2395) is internal and carries the token. Legal source cannot reach it — a merge's declarations must agree about being exported — which is why the fold keeps one rule for every scalar rather than deriving this one from the union |
| LP7 | const f = () => ... | name = f (the variable name becomes the qname) |
| LP7a | the same binding written behind a wrapper that says nothing about the value — TypeScript's (f), f as T, f satisfies T, f! | the same answer as LP7. A wrapper the language uses to group, to name a type or to assert non-null does not replace the value, so the test for "is this a function?" reads through it. One reader answers what a wrapper is, for every question the plugin asks about a node — a binding, a member, a call's argument, a call's receiver and a default export's value (LP6a). Two readers is the defect, not the duplication: while the receiver side hand-unwrapped only parentheses, (app as Express).get(p, h) was not a registration at all |
| LP7b | a call in the same position — const f = withAuth(() => …) | not a function. A call returns one by convention and nothing in the tree says so, which is the line the unwrap stops at: past it the plugin would be guessing rather than reading. That line decides the kind only — the function written inside the call is still read, as the const's body (LP7c) |
| LP7c | LP7b's const, export const POST = withAuth(async (req) => { … }) — and memo(function Row() {…}), forwardRef((p, r) => …), t.procedure.query(() => …) | still a const with a null signature, and the functions written as direct arguments of a call on the initializer's spine are its bodies, the first as bodyNode and the rest on mergedDeclarations — LP20g's reading and LP20h's layout — with derivedBy carrying call-argument-function. LP7b decides what the const is; the function is still written in it and in no other declaration, so leaving it unwalked put its rules, calls and effects on no Symbol at all. The wrapping call is not a body, so withAuth is not one of the const's calls. Each function is a walk root, so a concise arrow's expression body is read as its return value (LP19a): const doubled = xs.map((x) => x * 2) reports return: x * 2, as xs.map((x) => { return x * 2 }) does. The reading asks nothing about the call, so it is wider than the wrappers: users.map((u) => u.name), xs.filter(…).map(…), Array.from(xs, (x) => …) and compareBy((item) => item.id) give their consts a body too. What it does not reach, each measured: a function inside an argument (withAuth(withLogging(async () => { … })), past the line LP20g draws) or in an object property (useQuery({ queryFn: () => { … } })); a function handed to new (new Promise((resolve) => { … })), since new_expression is not a call; an initializer behind await (await withRetry(() => { … })), which the unwrap does not read through; a generator argument, outside LP7a's set at every site; a body of no width; a destructuring declaration (const { GET, POST } = createHandlers(…)), whose bindings have no body of their own; and export default withAuth(() => { … }), which produces no Symbol at all. This answers the module-level const only. A class field written the same way (handle = withAuth(() => { … })) is a separate site and still gets no Symbol of its own: its calls stay on the class, and a class of such fields is hinted as a pure DTO and dropped with them. The normalized string is the whole declaration, as LP20i's is the whole call: narrowing to the body would make withAuth(async (req) => { … }) and withRole(async (req) => { … }) serialize identically, and it keeps the string the const had before it had a body |
| LP7d | a binding initialised by an object literal holding functions — export const api = { get: async () => fetch("/x"), post() { send() } } | one member SymbolCandidate per function the object holds, api.get and api.post: kind = "method", visibility = "public", derivedBy carrying object-method, plus property-assigned-function for the pair spelling and accessor-declaration for get / set. LP20f's reason applies unchanged: defining the object creates the closure and only calling the property runs the body, so the body is the member's and the binding stops carrying it (LP20a). The binding stays a const with a null signature; its body is the object literal less those members, so client: makeClient() is reported on api, where it runs, and derivedBy carries object-literal-initializer to say why a const has a body. The member set is LP7a's, read through its wrappers: a method_definition — the class body's node, with its accessor, generator and async spellings — and a pair holding an arrow or a function expression. The initializer is read through them too, as const and satisfies Api being how a handler map is ordinarily written, and let and var are read as const is. A body of no width is refused, as LP20g and LP7c refuse one: a half-written get: () => still parses as an arrow whose body is a zero-width placeholder, and a member for it would publish a method the source does not write. kind is a class member's whichever way the entry is written, so a reader keyed to a module-level function by its name — a Next.js route verb, a React component or hook — does not take { GET: () => … } or { Button: () => <b /> } for one; visibility is a class member's with no modifier, since an object literal can write none. The range is the entry's and the signature the function's, as LP20f's are. An accessor pair or a repeated key is one SymbolCandidate, the getter leading (LP8a). A namespace holding only types may be written beside the binding, and its export type get and the member api.get then fold, led by whichever is written first (§4.3.1). The binding's normalized string is still its whole declaration — the object is not the declaration's own child — so no binding's syntax fingerprint moves, and an edit to a member moves the binding's as an edit to a method moves its class's |
| LP7e | an object literal nested under a named entry — { v1: { get: () => … } } | its members one segment further down, api.v1.get, at any depth: the dotted path is how the source reaches the function. The depth costs one SymbolCandidate per function written and none per other value, so a configuration object of strings and numbers mints nothing, and the object in between declares no SymbolCandidate of its own. A dotted call target composes past the binding without one (call-resolution.md §4.3), so api.v1.get() reaches the member. What the nested object evaluates stays on the binding |
| LP7f | an entry LP7d does not admit — a computed key ([k]: () => …), a number, a quoted key that is not an identifier ("a-b"), a key the parser recovered, a #-private name; a value that is not a function in LP7a's set (withAuth(() => …), function* () {}, [() => …]), or a function with a body of no width; a shorthand; a spread | no SymbolCandidate, and what the entry evaluates stays on the binding's walk — LP20c's answer for a class member. The key gate is a class member's (LP8m), so a quoted key that spells an identifier ("get": () => …) is the member get and a quoted "#v" is refused. The #-private name is where the two diverge: a class body reads #v as its private member (LP8o), but outside a class body #v is a SyntaxError, which the grammar parses anyway, so there is no private member for the segment to name. The object reader refuses it ahead of the shared gate, which would admit it, so the refusal is the reader's own and nothing behind it repeats it: without it the member's qualified name cannot be built and extraction throws. An object nested under a key with no segment is the binding's whole, functions included |
| LP7g | an object literal no binding names, or one a binding does not hold directly — export default { fetch() {} }, module.exports = { … }, an object handed to a call (Object.freeze({ … }), useQuery({ queryFn: … })), an element of an array, the initializer of a destructuring declaration — and an object a class field holds (handlers = { get() {} }) | no member SymbolCandidate. Nothing refuses them: the object reader is asked only of a binding's initializer and of an entry nested under one (LP7e), and none of these reaches it. So the limit rests on where objectLiteralOf is called rather than on a check, and a reader added at another site moves it. Why it is drawn here: <default>.fetch is not a qualified name, for LP6's reason about an anonymous class; a call returns whatever it returns (LP7b), so an object handed to one names no path; a destructuring declaration's bindings have no body of their own; and a class field holding a handler map holds no function, so its calls stay on the class (LP20f). A known limit, each measured: the functions in the first five are in no Symbol, as they were |
| LP8 | nested class (Outer.Inner.method) | the . nesting in name is correct |
| LP8a | a getter and a setter for one property | one SymbolCandidate. The two are one member, and one id can only carry one Symbol (§4.3.1) |
| LP8b | LP8a's signature | the getter's. A property's type is what reading it answers; the setter's signature is the type of writing it |
| LP8c | LP8a's bodies | both, the setter's on mergedDeclarations. walkBody reports the calls in either of them |
| LP8d | a getter with no setter, or a setter with no getter | one SymbolCandidate, nothing merged into it |
| LP8e | a static accessor pair beside an instance pair of the same name | two SymbolCandidates — Class::v and Class.v are different entities |
| LP8f | an overload declaration beside its implementation | one SymbolCandidate, and the signature and body are the implementation's. The overload is written first, so a rule that took the first declaration would report the member as body-less and give it the wrong parameter types |
| LP8g | a class body of overload declarations with no implementation | no member SymbolCandidate. The same answer a top-level overload with no implementation gets, so the construct does not depend on where it is written |
| LP8h | a declaration merged with an earlier one — a reopened interface or namespace, a namespace augmenting the class or function above it | one SymbolCandidate, carrying the earlier declaration's kind and range and every declaration's derivedBy. Anything the later declaration declares in turn (N.b from a reopened namespace N, f.b under a function) is its own Symbol and must still be there; under a class it is named by LP8n |
| LP8n | what a namespace merged into a class declares — class C {} beside namespace C { export function m() {} } | an export is the class's static member, C::m, apart from an instance member C.m; everything under it sits under that segment (C::Inner.g, and C::K::s for a nested class's static). A statement the namespace does not export is local to it and keeps the dot (C.local, internal). An ambient namespace exports without the keyword, and a dotted namespace C.D {} makes D the member (C::D.x). The class is looked for in the statement list the namespace is written in, so one declared in another block of a reopened outer namespace is not seen and the exports keep the dot (A.C.x) |
| LP8i | a file that declares nothing twice | no evidence of merging anywhere in it, and the mergedDeclarations key absent — not present and empty. The two stretches of that field are the exception, and carry the key only for a second body: a call Symbol with more than one handler (LP20h) and a const handing its call more than one function (LP7c) |
| LP8j | a merged declaration that has no body of its own — a reopened enum / namespace / type alias | its fullNode still reaches normalizeAst, so editing the second declaration changes the Symbol's normalized string |
| LP8l | a declaration whose name spells a path the language treats as nesting — TypeScript's namespace A.B {} | one SymbolCandidate per segment, and the body under all of them. The dotted text is not one qualified-name segment, so feeding it to the id builder loses the whole file (§4.3) |
| LP8m | a member whose name is written as a string literal — class C { "createInvoice"() {} } | the segment the literal decodes to, when that is an identifier the qualified-name grammar admits; otherwise no SymbolCandidate (LP20c). A decoded "#v" has no SymbolCandidate either: the segment #v is the #-private member's (LP8o), and a quoted key is a public property whatever its characters, so the producer refuses the leading # — the refusal is the producer's, not the grammar's, and it is the default isQnameSegment answers with. A property key is a string, so the quoted and the bare spelling of one identifier are one member and fold by id (§4.3.1) — and "constructor"() {} is the construction path, which the language decides by the property name and not by how it is written. A name the parser guessed at is refused in both shapes it arrives in: a literal that parsed in part, and one that did not parse at all — the second leaves no literal to refuse, so what says so is an ERROR among the member's own children, never hasError, which a broken body would trip |
| LP8o | a #-private member — class C { #v() {} static #w() {} }, a #-private field holding a function, accessor pair | C.#v and C::#w, with the #, and visibility = "private". #v and a v written beside it are two members, so dropping the # folds two entities onto one id (§4.3.1). The segment is ECMAScript's PrivateIdentifier: one #, leading, and only after a separator (ir-schema.md §3.1). Only the private name node asks the id builder for it — core's isQnameSegment admits #v only with privateName set — so a string key cannot reach it (LP8m, LP20j). Visibility is read off the node type rather than the segment's #, which carries it only because the string refusal holds. #constructor has its own segment and is an ordinary member, never the construction path |
| LP8k | a decorator written on a merged declaration that is not the leading one | among the Symbol's decorators. A boundary decorator lost here re-enters the Symbol into drop-list.md Category B |
| LP35 | a member declared abstract — abstract class A { abstract doIt(): void } | a member SymbolCandidate, kind = "method", bodyNode null, derivedBy carrying abstract-declaration. The language forbids an implementation beside it, so nothing else in the class declares the member, and skipping it left an abstract class reporting only the methods it happened to implement — where the declared one is the API the class promises and what a subclass is checked against. An abstract accessor pair is LP8a's fold. Abstract overloads fold too, but LP8f's rule cannot apply as written — it takes the signature from the implementation, and an abstract pair has none — so the Symbol keeps the leading declaration's signature. An abstract field is a field, on LP20f's terms: it holds no function |
| LP36 | a declaration written under declare — declare function f(): void, export declare class C { m(): void }, declare namespace N { … }, declare const / enum / interface / type | the SymbolCandidate the same declaration gets written without the keyword, plus ambient-declaration on derivedBy, and the members and nested declarations under it. declare is a modifier the grammar spells as a wrapper node, so it is read through rather than matched on — including between export and the declaration, where a reader taking the immediate parent finds the wrapper rather than the export, and above it, where the JSDoc is written. One divergence is recorded rather than closed: an ambient namespace exports its members whether or not they carry the keyword — declare namespace N { function h(): void } makes N.h resolvable, and only an export {} or export = in the body switches that off — where computeTopLevelVisibility looks for an export_statement parent and so answers internal for h. Closing it needs visibility to be a question about the enclosing scope rather than about the statement, which nothing else in this table asks for. .d.ts files are dropped before extraction (drop-list.md Category A); these forms are written in ordinary .ts files and are not |
| LP36a | a signature with no body — function f(): void, class C { m(): void } | a SymbolCandidate only under a declare (LP36). Written outside one it is an overload declaration, and the implementation beside it carries the body and the parameter types the entity is actually called with, so the implementation is the Symbol and the signature is not — the answer a top-level overload and a class member have to give alike, or the same source answers differently depending on where it is written. An ambient context has no implementations at all, so there is nothing beside the signature to defer to, and reading its members as overloads left export declare class with no methods |
| LP36b | a declare that augments a scope other than this module — declare global { … }, declare module "express" { … } | no SymbolCandidate, for the augmentation or for anything inside it, and no diagnostic. What either declares is a member of the global scope or of another module, and the only qualified name available is a top-level one — which would claim that name for this file and fold with the file's own declaration of it (§4.3.1). The specifier is not a qualified-name segment either, so building an id from it throws and costs the file every Symbol it had (§4.3). A known limit: covering these needs a qname convention for a scope that is not the file's, which ir-schema.md §3.2 does not have |
9.2 Signature extraction
| ID | Input | Expected |
|---|---|---|
| LP9 | async function f() | signature.async = true |
| LP10 | function* g() | signature.generator = true |
| LP11 | f(a: number, b: string): boolean | inputs = [{name:"a",type:"number"},{name:"b",type:"string"}], outputs = ["boolean"] |
| LP11a | a function that states a parameter without a parameter list — TypeScript's parenthesis-free arrow, x => x + 1 | inputs = [{name:"x",type:""}], the same the parenthesised spelling (x) => x + 1 reports |
| LP12 | function f<T>() | typeParameters = ["T"] |
| LP13 | function f() { throw new MyError() } | throws = ["MyError"] |
LP11a is where the two spellings of one parameter have to agree. inputs is compared positionally by the api fingerprint (fingerprint.md §3.1), so a reader that knows only the parameter list reports the wrong arity in both directions: dropping the parameter (x => … → () => …) reads as no change at all, and adding the parentheses (x => … → (x) => …) reads as an api change.
9.3 Decorator extraction (languages with decorators)
| ID | Input | Expected |
|---|---|---|
| LP14 | @Post('/x') method() | decorators[0] = { name: "Post", raw: "Post('/x')", arguments: ["'/x'"], boundary: <determined by the framework plugin>, line: ... } |
| LP14a | @nest.Post('/x') method() | name = "Post", qualifier = "nest", raw = "nest.Post('/x')". The leaf and the receiver are reported apart so a consumer can tell a namespace import from a bare name (§5.2.2); raw still quotes the whole written form |
| LP14b | a decorator written as a bare name (@Post(), @Post) | no qualifier key at all — Class B per ir-schema.md §1.1, never null and never "" |
| LP14c | a nested receiver (@a.b.C()) | qualifier = "a.b", carried whole. Which segment names a binding is the consumer's rule, not the extractor's |
| LP14d | a receiver that is not a plain path — @arr[0].C(), @(a).C(), @pick().C(), @ns["C"]() | decorators = [], so there is nothing to qualify. Not because the grammar refuses them: each parses as a decorator of the fragment the grammar could take, inside an ERROR node that leaves it no longer a preceding sibling of the declaration, which is what puts it out of the run. A plugin must not read this as a grammar guarantee — it is where error recovery happens to put the node |
| LP14e | a receiver the grammar reaches but that binds nothing — @this.C() (parses cleanly), @a?.C() (recovered, the ? an ERROR child of the member expression) | the object verbatim: this, and a. The extractor quotes what was written; a consumer matching it against an import edge finds nothing and falls in the unbound row of §5.2.2 |
| LP14f | a path written in parentheses — @(a.b) | name = "b", qualifier = "a", as for @a.b. The rule is the extractor's own and not the grammar's: a qualifier is reported when what the parentheses enclose is a member expression with an object, and in no other case |
| LP14g | a name, a path or a call in parentheses, which the grammar parses cleanly — @(Controller), @(nest\n .Controller), @(Controller('/x')) — or with a repair only inside the argument list, @(Controller(a b)) | read through the parentheses: Controller, Controller with qualifier nest, and Controller with arguments ["'/x'"], so each matches the vocabulary its unparenthesized form does. A comment inside is skipped. raw quotes the parentheses, and a line break inside them stays in raw and out of name |
| LP14h | a parenthesized expression the grammar had to repair — @(x as any), @(x!), @(a[b]), @(C<T>), @(new C()), @(a, b), @(pick().C), @(a?.C), @(nest.) | one decorator, name: "<expression>" (UNNAMED_DECORATOR in @aburi/types), no qualifier, no arguments, the text in raw. TypeScript accepts any expression there and the grammar's decorator rule does not, so the parse holds an ERROR node beside a fragment (x, a, a call of new) that is not the decorator. The repair that counts is one in the head, an ERROR or MISSING token among the children of the parentheses or of what they enclose; a broken argument list nests deeper, so @(Controller(a b)) is Controller, as @Controller(a b) is (LP14g). @(a?.C) is the one form whose unparenthesized spelling reads differently: LP14e names @a?.C() C with qualifier a, from the same repaired member expression. The difference is this row's choice, since a repair inside parentheses is refused wherever it sits. Decorator.name is never a node's text: it is an identifier or this marker, which begins with < and so matches no vocabulary |
| LP15 | two decorators | 2 entries in decorators[], ascending by source position — not by line, which two on one line share, and never by name, which would make a consumer that reads the first one depend on the alphabet. On a Symbol several declarations wrote (§4.3.1) the order is still source position, across all of them: the lists join in declaration order, which is why the fold visits declarations in source order rather than leading declaration first |
| LP15a | a decorated member followed by an undecorated one | the second member's decorators = [] — the run belongs to the member it sits above, and does not leak down |
| LP15b | a comment between two decorators, or between the decorators and the declaration | all of the decorators, in source order — a comment is written wherever the author put it and does not end the run |
| LP15c | a decorator the grammar parents inside the declaration rather than beside it (@A() class C {}, export @A() class C {}, export default @A() class C {}, @A() abstract class C {}) | read the same as a preceding one — where a decorator is written must not change what the Symbol reports |
| LP15d | a parameter decorator (m(@P() x)) | not among the method's decorators, nor the owning class's — it decorates the parameter |
| LP15e | a JSDoc block above a decorator (/** @throws E */ @A() m() {}) | read as the member's; the decorator does not end the comment run |
| LP15f | a decorator on both sides of export (@A() export @B() class C {}) | both. TypeScript rejects the source as TS8038, but a grammar that accepts it hands the extractor a half-edited file, and reading one side would drop a decorator from it silently |
9.4 Body walk
| ID | Input | Expected |
|---|---|---|
| LP16 | if (x) throw new E() | guard + throw in rules |
| LP17 | return 1 | no return in rules (trivial) |
| LP18 | return foo() | no return in rules, foo in calls |
| LP19 | return a + b | return in rules (expr: "a + b") |
| LP19a | an arrow with an expression body — (u) => u.role === "admin", wherever the arrow is a walk root (a variable, a class field per LP20f, a property of a binding's object literal per LP7d, a registered handler per LP20g, a function a const hands its call per LP7c, a default export) | the rules its block twin function f(u) { return u.role === "admin" } gets: the body is the arrow's return value, so it goes through the triviality and call-only tests a return does (drop-list.md §5.3–§5.5). Two outcomes differ, both where the block spelling is the less exact one: one pair of parentheses is taken off before the tests, so () => (g()) is call-only, as return g() is, where return (g()) takes a rule; and the body is walked whole, so (a) => a[g()] records g where return a[g()] does not. Read as a bare expression the body earned no rule, and an edit to it moved no logic fingerprint where the block twin's does. The rule is the only addition: the body's own walk is unchanged, so it records the same calls whether or not the rule is added |
| LP20 | for (let i...) ... | loop in rules (loopKind: "for") |
| LP20a | a type whose members are Symbols of their own — a class and its methods, and a binding's object literal and its members (LP7d) | the owner's walk reports its own body only: field initialisers, static blocks, and whatever the language runs on construction — for an object literal, the values defining it evaluates, at every depth LP7e reads. A member body reported twice is reported on two Symbols, and any resolution that reaches the owner (call-resolution.md CR15 resolves new C() to the class) carries the duplicate up into callers that touch nothing |
| LP20b | the construction path — a constructor body | reported on the owner, because that is the Symbol an instantiation resolves to, and on the member's own Symbol. Reporting it twice costs nothing only while no call resolves to a constructor; a resolver that grew super() resolution (call-resolution.md CR18 is adjacent) would make the second copy propagate, so the plugin owes this row a check against its core. Whether a static member named constructor is on the construction path is the language's answer, not the name's: in ECMAScript it is not |
| LP20c | a member with no Symbol of its own — a computed name, a name the qualified-name grammar has no segment for ("a-b"() {}, 1() {}), a quoted "#v" (LP8m), a member of an anonymous default export, an object literal's entry LP7f does not admit | its body stays on the owner. Skipping it would lose the calls in it with no Symbol, no diagnostic and nothing to say so. The plugin needs one predicate for "is this member a Symbol?" that both extraction and the walk read — one for a class body and one for an object literal's entries — and both must ask it about the same owner node. The walk reads the owner off the body it is walking, not off the Symbol: a folded Symbol's fullNode is its leading declaration (§4.3.1) and need not be the owner at all |
| LP20d | a function's parameter defaults — function f(x = g()), (y = k()) => l(), m(x = f()) {}, an inline handler's | on the function's own Symbol, walked with its body and ahead of it. A default runs on every call that omits its argument, so it is what calling the function runs; outside a class there is no other Symbol to carry it, and a default not walked here is in the IR nowhere. For a member the owner then skips the parameter list as it skips the body (LP20a), so the default is not reported twice — except the constructor's, which the owner keeps whole under LP20b, so it is on both. A decorator is not a default and stays on the owner, whether it sits beside the member (a method's, a sibling in tree-sitter-typescript), inside the field, or on a parameter (m(@Body(pipe()) x)): a decorator's arguments run where the decorator is applied, when the class is defined |
| LP20f | a member written as a field holding a function — create = (d) => { … } | a member. Constructing the owner creates the closure and does not enter it, so the body is what calling the member runs, and LP20a applies unchanged: the body is the member's Symbol's and the owner stops carrying it. What separates it from seed = makeSeed(), which stays on the owner, is when the value runs — not what the member is called or how it is spelled. The function set is the one the language's module level already uses for the same decision, so const f = … and class C { f = … } are not two answers. The member's body is one level further down than a method's, so the skip follows the path to it rather than filtering the member's own children |
| LP20g | a Symbol whose declaration is a call that registers a function — app.post('/users', async (req, res) => { … }) | the registered function is the Symbol's body. The registration is the only Symbol the statement produces, so a handler written inline is otherwise in no Symbol at all; and it is what the registration runs, so it is the body by the same reading as LP20a. Every function written as a direct argument of a call on the statement's spine counts, in source order — a chained registration (app.route(p).get(h1).post(h2)) is one statement and one Symbol, and so is app.use(h0).router.get(h1), where a property access stands between the two calls. "Function" is the plugin's own predicate (LP7a) and no wider: a generator argument (Koa's app.use(function* (ctx, next) {…})) registers no body. A body of no width is refused, so a half-written handler the parser recovered does not claim one. The Symbol's signature stays null: it is the registration, and reading the handler's would publish the framework's callback shape as the route's API |
| LP20h | LP20g with more than one handler | the first in source order is bodyNode and the rest ride on mergedDeclarations, which is a stretch of that field: they are further bodies of one declaration, not further declarations of one entity (§4.3.1), so each entry's fullNode is that declaration, and they carry no declaration-merged. The field is reused because every reader of it (bodyNodesOf, the empty-body hint) wants exactly "every body this Symbol has". A single-body reader gets the source-order-first function, which need not be the handler: makeRouter(() => setup()).get(p, h) leads with the factory's callback |
| LP20i | LP20g's normalized string | the whole call, not the body. What the registration runs is the walk's question; what it is — its path, its method, the middleware between them and the handler — is the fingerprint's, and narrowing to the body makes app.get(p, authenticate, h) and app.get(p, h) serialize identically. This is LP8j's rule read from the other side: a describing node dropped from the string is a change the axis stops seeing |
| LP20j | prisma["user"].create(d), prisma.user["create"](d) | one call each, target prisma.user.create, no dynamicReceiver — a string-literal index is the segment it spells, in the receiver slot and in the terminal one (§4.4), unless that segment is a private name (LP20k) |
| LP20j1 | prisma["user" "audit"].create(d), obj["a\u12b"].m() | prisma.<computed>.create / obj.<computed>.m — an index the parser recovered rather than read names no segment, whether the ERROR stands beside the literal or inside it |
| LP20k | prisma[model].create(d), items[0].save(), obj["a-b"].m(), obj["#v"].m() | one call each, targets prisma.<computed>.create, items.<computed>.save, obj.<computed>.m, all dynamicReceiver — an index that names no segment is reported as one that names none, where dropping it would report a call the program does not contain. "#v" spells the private segment and still names none: obj.#v is another member (§4.4) |
| LP20k1 | handlers[name](), prisma.user[verb](d) | handlers.<computed> / prisma.user.<computed>, both dynamicReceiver — the terminal slot answers the same way, so a call whose name is computed is reported as a call rather than as a call to its receiver |
| LP20l | [...names].sort(), 'a...'.trim(), (.5).toFixed(2), new Date().getTime(), (async () => { … })(), ([...a] as T).m() | <computed>.sort, <computed>.trim, <computed>.toFixed, <computed>.getTime, <computed>, <computed>.m, all dynamicReceiver — an unmodelled receiver contributes the reserved segment, never its source text (§4.4). svc!.save() stays svc!.save, not dynamic, and import("./m") stays import. A wrapper whose text spans lines, (x as { … }).m(), is <computed>.m |
| LP20e | an owner-shaped node the walk meets rather than owns — function f() { class Inner { m() { x() } } } | walked whole. Inner is not extracted, so every call in it belongs to f; LP20a applies to the Symbol's own body nodes and to nothing else |
9.5 normalizeAst
| ID | Input | Expected |
|---|---|---|
| LP21 | code differing only in comments | normalizeAst yields identical strings |
| LP22 | code differing only in whitespace | normalizeAst yields identical strings |
| LP23 | code with different identifiers | normalizeAst yields different strings |
| LP23a | code differing only in an operator, a declaration keyword, a modifier or a primitive type — a + b / a - b, let / const, private / public, x as string / x as number | normalizeAst yields different strings. These are unnamed tokens in most grammars, and a walk over named nodes alone drops them (fingerprint.md §5.1) |
| LP23b | code differing only in punctuation fingerprint.md §5.1 item 4 leaves out — for example the quotes around a string that needs no escape either way, or ; against , between interface members | normalizeAst yields identical strings |
| LP23c | code differing only in a hole in an array or an array pattern — [, token] / [token], [1, , 3] / [1, 3], [x, ,] / [x,] | normalizeAst yields different strings. The grammar has no node for a hole, so the commas around it are all that record it: a comma right after [ or after another comma is kept, and every other comma, a trailing one included, is dropped. A destructuring parameter is outside the body this string describes, so a hole in one is not covered here |
9.6 Import extraction
| ID | Input | Expected |
|---|---|---|
| LP24 | import { X } from './y' | imports = [{source: "./y", symbols: ["X"], line: 1, dynamic: false}] |
| LP25 | import * as Y from 'z' | imports = [{source: "z", symbols: "*", line: 1, dynamic: false}] |
| LP26 | await import('./x') | imports = [{source: "./x", symbols: "*", dynamic: true}] |
| LP26a | an empty specifier on a form the reader already produces an edge for — import a from "", import "", export * from "", export { X } from "", import type { B } from '', import(""), import x = require(""), import()`` | no edge, and one recoverable ParseError at the literal's line and column, naming which construct it belongs to. The specifier names no module, so no edge can carry it (§4.4), and a silent drop would leave the file looking as though the import were never written |
| LP26b | an empty specifier beside a valid one | only the broken edge is withdrawn |
| LP26c | two empty specifiers, on one line or on two | two errors. Edges are deduplicated on (line, source, dynamic, symbols), so the only pair that could ever have merged is two writings on one line; diagnostics are per occurrence either way |
| LP26d | a blank specifier (import a from " ") | an ordinary edge. §4.4 rejects empty, not blank |
| LP26e | a computed specifier — import(p), import("" + x), import(`./${p}`) | no edge and no diagnostic. The reader does not follow computed specifiers, which is not a fault in the source. Joining a substituting template's fragments would answer "./" here — an edge to a module nobody named, which is worse than none |
| LP26f | import x = require('./m'), written at the top level of a file | one edge, {source: "./m", symbols: "*", line: 1, dynamic: false, namespaceBinding: "x"}. x binds the module object as import * as x does, so the namespace shape is what lets call resolution strip the head off x.foo() and look for foo in the target; a default binding (symbols: ["x"]) would send it looking for x.foo there instead. dynamic is false because the field means "written as import()" (§4.2) and this form is not |
| LP26g | import type x = require('./m') | the same edge. Type-only imports produce edges throughout §9.6 |
| LP26g1 | a require-equals whose argument is not a lone string literal — require("a" + b), require('./m', 'y'), require(f('./m')) | no edge. Each is a syntax error the parser already reports, but tree-sitter's recovery leaves an operand it could read as a direct child of the clause with the source field attached, so a reader that took it would answer a, or the second argument. A clause that did not parse is not read |
| LP26g2 | export import x = require('./m'), and a require-equals inside declare module / namespace | no edge, and the reader does not follow either form. Both are valid TypeScript. The export form is a grammar gap: tree-sitter reads it as an import_alias with a MISSING ";" plus a stray parenthesised expression, so the clause never exists to be read. The nested form is the general limit of walking root.namedChildren — a plain import { A } from './m' inside declare module is missed on the same terms — and is softened by .d.ts files being dropped before extraction |
| LP26h | import x = A.B.C | no edge and no diagnostic. The alias renames a local namespace; no module is named |
| LP26i | import(/* webpackChunkName: "x" */ './m') | the ordinary dynamic edge. A comment is a named node standing in front of the specifier, and reading the first argument by position would find the comment |
| LP26j | import(`./m`) | the ordinary dynamic edge. A template with no substitution names a fixed module and is a static specifier written with different quotes |
| LP26k | a specifier carrying an escape sequence — "./a\tb", "\x2E/e", import(`./a\nb`) | the escape is decoded, not skipped: ./a+TAB+b, ./e, ./a+LF+b. Skipping it answers a module that does not exist, and for an escaped leading dot it answers one that is no longer relative — so isRelativeSpecifier refuses it (the predicate is a ./ or ../ prefix, or exactly . or ..) and every call through the binding is bucketed external rather than resolved against the sibling file it names |
| LP26l | an escape the grammar refuses — "./a\uZZZZb", "./a\xZZb" | recoverable syntax errors from the parser, and the fragment that survived. These parse as ERROR nodes rather than escape_sequence, so no decoder is asked to repair source the grammar rejected. The criterion is that the grammar rejected it — see LP26n for the escapes it accepts without a legal value |
| LP26m | a literal that is only a line continuation — "\<newline>" | no edge, and the LP26a diagnostic. A continuation joins two source lines and contributes no character, so the literal names no module. A literal made only of other escapes ("\n\t") is a non-empty name and gets an ordinary edge, on LP26d's terms |
| LP26n | an escape the grammar accepts and ECMAScript has no value for — "./a\u{110000}b", "./a\1b", "./a\8b" | the escape's own text, minus the backslash, with no diagnostic and no throw. The grammar checks a braced escape's shape and not its range, so \u{110000} reaches the reader where \uZZZZ does not, and String.fromCodePoint throws on it — which would cost the whole file. Legacy octal and non-octal decimal escapes are the same case: a SyntaxError in a module, so no correct value exists, and inventing one would put a module name in the IR the source does not contain |
9.7 Error recovery
| ID | Input | Expected |
|---|---|---|
| LP27 | file containing a syntax error | returns a recoverable error, extracts Symbols where possible |
| LP27a | a bare & in JSX — <p>Subscription & Billing</p>, href="/x?a=1&b=2" | no parse error. The tsx grammar reads & as the opening of an HTML character reference and errors when no ; closes it, which makes prose and a tracking URL indistinguishable from a broken file; the plugin drops that one shape so the recoverable-error count stays worth reading. Narrow on purpose: the run has to open with an ampersand-led token, sit among a JSX element's children or inside a JSX attribute's string, and — among children — hold none of {, }, <, >, which JSX text cannot contain. The last rule is what stops an & swallowing a real break merged into the same ERROR node, as in <div>a & } b</div>; it does not apply inside an attribute's string, where all four are ordinary. A truncation whose ERROR sits outside both positions still reports, including in a file that carries both |
| LP27b | an import("…") type the grammar reads as a call and cannot place — first in a call's type arguments (importActual<typeof import("./m")>(), the vitest mocking idiom) or before [] (import("./m").Rule[]) | no parse error, and the declarations after it survive, including a run of them that recovery reads as the whole file broken. The file is parsed a second time with each such import(…) replaced by an identifier of the same length, and the tree reads the original text, so no Symbol name, call target or node.text shows the replacement. Node kinds are the second parse's: the import(…) is an identifier there, so it adds no import call target (a clean typeof import("./m") in a body adds one), and the masked shape is what normalizeAst serialises. Its import edge is kept, so type-only imports produce edges here as LP26g says. Only an import(…) with a non-empty string specifier, on one line, in the nearest statement, declaration, member definition or signature holding an error, or inside an ERROR node, is replaced, so a type alias that parsed keeps its tree and import("") keeps its empty-specifier error; a replacement the second parse does not read as a type is withdrawn, so a dynamic import("./x") in the same statement stays a dynamic import; and the second tree is used only when it has fewer errors. When the second parse throws, the first tree is kept and the failure is one more recoverable error |
| LP27c | a type parameter's in / out variance modifier — interface A<out T>, class E<in out T = X>, class F<const out T> | no parse error. The typescript grammar has no rule for TypeScript 4.7's variance annotations, and recovery splits the parameter's words three ways: <out T> usually leaves ERROR "T" after a parameter named out; with a constraint or a default the ERROR sits inside that parameter, straight after the name; and in some longer lists each out is the ERROR, before a parameter that parsed clean. The plugin reads the words before the parameter's constraint and default back across those pieces and drops the ERROR when they are in, out or in out and then one name, in the type parameters of a class, an interface or a type alias. A const among them is allowed on a class only, and the name has to be one tsc accepts for a type parameter. So <out out T>, <out in T>, <out T U>, <out in>, interface A<const out T>, a name after a constraint, a stray token and a modifier on a function's or method's own type parameters still report, as tsc's parser rejects them too. The rule does not read a type alias's right-hand side, so type A<out T> = T is silent although tsc's checker refuses it (TS2637). The Symbols are the same with and without the modifiers: the declarations that admit one carry no Signature for the misread name to reach. normalizeAst is the exception for a type alias, which it serialises whole: the grammar reads the modifier as the parameter's name, so an annotated alias's parameter rename does not change it |
| LP28 | completely broken file | returns a non-recoverable error and a null tree, the core withdraws it |
| LP28a | a file the plugin parsed but refuses — a usable tree paired with a recoverable: false error | the core withdraws it on the same terms as LP28. The tree is never handed to extractSymbols, walkBody or normalizeAst, and the refusal is quoted in ScanResult.skipped |
| LP28b | a plugin emitting two Symbols under one id for one file | that file is withdrawn with reason: "extraction-failed" and code: "duplicate-symbol-id", naming the id and both declarations' lines; every other file still reaches the Document, and the run exits 3 like any other extraction failure. The file keeps its recoverable parse errors, unlike one a plugin threw on (§7.2) |
| LP28c | a plugin emitting a Symbol whose id names a file other than the one being extracted (§4.3) | the file that wrote the id is withdrawn on LP28b's terms, whichever of the two discovery reached first, and the message names the path the id claims against the file it came from. Withdrawing the file the id names would take a healthy file for another's fault |
9.8 Symbol id construction
| ID | Input | Expected |
|---|---|---|
| LP29 | Any extracted Symbol | SymbolCandidate.id came from makeSymbolId; a hand-assembled `${lang}:${file}#${qname}` does not type-check against SymbolId (§4.3) |
| LP30 | A qualified name the §3.2 grammar rejects (empty, anonymous marker, non-identifier segment) | makeSymbolId throws a coded CoreError; the id never reaches the IR |
9.9 Tree lifetime (§8.1)
| ID | Input | Expected |
|---|---|---|
| LP31 | A plugin holding a resource its trees do not release on their own | it implements releaseTree, and a tree passed to it is unusable afterwards |
| LP32 | Many files scanned in one process | the parser's heap does not grow with the file count — the core released every tree the plugin handed over |
| LP33 | A file whose extractSymbols / walkBody / normalizeAst throws, is withdrawn, or overruns parseTimeoutMs | its tree is released all the same; the diagnostic the file was already carrying is what the caller sees |
| LP34 | A releaseTree that throws | the file keeps its Symbols and its exit code; the plugin, the file and the message are on ScanResult.treeReleaseFailures |
10. Design Decisions
10.1 Why effect classification is excluded from the plugin's responsibilities
If language plugins carried effect classification, @aburi/lang-typescript would have to know hundreds of call patterns for Prisma/Drizzle/Stripe and so on. To avoid responsibility bloat, the separation is strictly enforced: language plugins handle structure, effect plugins handle identifier patterns.
10.2 Why the final drop decision is excluded from the plugin
The drop list (drop-list.md) is evaluated by integrating input from the config and other plugins. The language plugin only reports the fact "this is a type alias" as a DropHint; the core makes the final drop decision.
10.3 Why fingerprint hashing is excluded from the plugin
Fingerprint consistency (fingerprint.md §8) must be guaranteed across languages. SHA-256 / 12-hex truncation / canonical JSON are implemented in one place in the core; plugins only return normalized strings.
10.4 Why ImportEdge comes from the plugin
Import statement syntax differs greatly per language (TS import, Python from X import Y, Go import (...)). Having the core understand each language's imports directly is too heavy a burden, so it is delegated to the plugin.
10.5 Why extractSymbols and walkBody are separate
Extraction is two-phased:
- Produce the list of symbol candidates (framework plugins can return extKind hints here)
- Walk each symbol body (the walk must happen after extKind is decided)
This anticipates cases where, after the framework plugin decides the extKind, walkBody wants to change its behavior based on that information (e.g. treat @Body parameters specially for a NestJS Controller).
10.6 Why normalizeAst is a separate function
Syntax fingerprint computation is a step independent of IR assembly. normalizeAst is not called for dropped symbols (fingerprint.md §6). Separating it explicitly minimizes overhead.