Plugin development
Aburi has three plugin types. They share a manifest contract but do very different things:
| Type | Contract | Emits |
|---|---|---|
| Language | parseFile, extractSymbols, walkBody, normalizeAst, optional symbolDropHint | SymbolCandidates + BodyExtraction (rules + calls) |
| Framework | classifySymbol | SymbolClassification (extKind, derivedBy, decorator boundaries) |
| Effects | classify | EffectClassification (effectId, derivedBy, confidence) |
The full type signatures live in @aburi/types and the design contracts in the language plugin spec, the extension vocabulary spec (framework classification lives inside the language plugin spec §5.2 — there is no separate framework-plugin spec today), and the effect plugin spec.
This document is the operator-facing walkthrough.
Manifest
Every plugin exports a PluginManifest and Aburi validates it against schema/aburi.plugin.v1.json. The manifest is the only place a plugin declares what vocabulary it owns — the runtime VocabRegistry uses the declaration to enforce namespace ownership and reject cross-plugin collisions at load time.
import type { FrameworkManifest } from "@aburi/types"
export const myFrameworkManifest: FrameworkManifest = {
$schema: "https://aburi.dev/schema/aburi.plugin.v1.json",
name: "framework-mytool",
version: "0.1.0",
type: "framework",
engines: { aburi: "*" },
provides: {
effects: [],
effectPrefixes: [],
extKinds: [
{
id: "framework:mytool:widget",
baseKind: "class",
description: "MyTool widget class declared with @Widget().",
},
],
extKindPrefixes: ["framework:mytool"],
derivedByPrefixes: ["framework:mytool"],
frameworks: ["mytool"],
},
}Key rules:
extKinds[].idmust be namespaced under one ofextKindPrefixes.derivedByPrefixesclaims the tag prefixes your plugin will emit inderivedBy. EveryderivedByentry the plugin returns at runtime must fall under one of these prefixes.frameworksis used byaburi initautodetect to route framework names inaburi.jsonto your plugin.
Two plugins declaring the same extKind id → the registry throws at register time. This is intentional — we prefer a loud fail over a silent takeover.
Language plugin
Parses a file, extracts Symbols, walks bodies to collect Rules + Calls, and computes a normalised AST string for fingerprinting.
import type { LanguagePlugin, ParseResult, BodyExtraction } from "@aburi/types"
class MyLangPlugin implements LanguagePlugin {
readonly manifest = myLangManifest
readonly fileExtensions = [".my"] as const
readonly capabilities = { /* boolean matrix */ }
async init() {}
async parseFile(file): Promise<ParseResult> {
// Return { tree, errors, imports }. `tree` is opaque to the pipeline.
}
extractSymbols(tree, ctx) {
// Return SymbolCandidate<Node>[]. Every id must start with `<language>:`.
}
walkBody(symbol, ctx): BodyExtraction {
return { rules: [], calls: [] }
}
normalizeAst(symbol) {
// Canonical AST string. Whitespace / comment insensitive. Identifiers preserved.
return ""
}
symbolDropHint(symbol, ctx) {
// Optional: language-specific drop candidates the shape-only core rules
// cannot judge (e.g. `{}` empty body detection).
return null
}
}Contracts to know:
- Every
SymbolCandidate.idmust begin with<language>:(the language prefix claimed by your manifest). The pipeline throws otherwise. walkBody'sRuleoutput is line-sorted by the pipeline before entering the IR — you do not need to sort it yourself. Same forcalls.symbolDropHintreturns{ reason, category: "B" | "C" }ornull. Category B drops mean "the Symbol never makes it into the IR" — use this for genuine boilerplate. Category C means "the Symbol is kept but its calls are pruned".- Errors from
parseFileare recoverable — return the tree you built and add the errors toParseResult.errors. AterminalParseFailure(thrown) skips the file entirely and surfaces in the CLI'sparseErrorCount.
Framework plugin
Classifies a SymbolCandidate into a framework-owned extKind and optionally overrides decorator boundaries.
import type { FrameworkPlugin, SymbolClassification } from "@aburi/types"
class MyFrameworkPlugin implements FrameworkPlugin {
readonly manifest = myFrameworkManifest
async init() {}
classifySymbol(candidate, ctx): SymbolClassification | null {
// Return null to abstain; the next framework plugin gets a turn.
if (!candidate.decorators.some((d) => d.name === "Widget")) return null
return {
extKind: "framework:mytool:widget",
derivedBy: "framework:mytool:widget",
// Optional: flip boundary flag for specific decorators.
decoratorBoundaries: { Widget: true },
}
}
}Contracts:
- First non-null classification wins. Order matters — the CLI walks frameworks in the order they appear in
aburi.json. derivedBymay contain;-separated multi-signal reasons; the pipeline splits them into individualderivedBy[]entries.- Any
extKindorderivedByyou emit must be declared in your manifest'sextKindPrefixes/derivedByPrefixes.
Effects plugin
Classifies a CallCandidate into a core effect vocabulary (db.read / db.write / db.transaction / event.publish / net.fetch / fs.read / fs.write / …).
import type { EffectPlugin, EffectClassification } from "@aburi/types"
class MyEffectsPlugin implements EffectPlugin {
readonly manifest = myEffectsManifest
async init() {}
classify(call, ctx): EffectClassification | null {
// Pure — no I/O, no state, no async. The per-call timeout budget is 50ms
// by default and stateful classifiers are the reason it exists.
if (!looksLikeMyEffect(call, ctx)) return null
return {
effectId: "net.fetch",
confidence: "high",
derivedBy: `effects-plugin:mytool:${call.target}`,
}
}
// Optional: drop callees you know are pure-boilerplate (Category C).
readonly dropCallees = ["mytool.helper.formatUrl"]
}Contracts:
- First non-null classification wins across the effects plugin list.
- The classifier is called under a per-call timeout budget. Keep it pure so a slow classification is a bug, not a design decision.
- Two-signal layered gate is strongly recommended: check for both an import-time signal (does the file import the target library at all?) AND a call-site signal (does the target's segment shape match?). See
packages/effects-prismafor the reference pattern — a randomfoo.findMany()in a file that never imports@prisma/clientshould returnnull, and so shouldprisma.foo.bar()in a file that never uses Prisma's method vocabulary.
Registering with the CLI
Once your manifest and plugin object are ready, the CLI's plugin-loader discovers them by package name from aburi.json:
{
"$schema": "https://aburi.dev/schema/aburi.config.v1.json",
"languages": ["typescript"],
"frameworks": ["mytool"],
"effects": ["mytool"]
}The loader resolves each ref as follows (packages/cli/src/plugin-loader.ts:84-90):
- Bare name with no
@and no/→ prefixed with@aburi/verbatim."typescript"→@aburi/typescript,"framework-mytool"→@aburi/framework-mytool. The loader does not infer a bucket prefix — a config entryframeworks: ["mytool"]resolves to@aburi/mytool, NOT@aburi/framework-mytool. Third-party plugin authors should write the full package name (framework-mytoolfor a first-party bucket-prefixed name, or@myorg/mypkgfor a scoped package) inaburi.json. - Scoped or slash-containing (
@myorg/pkg,some-pkg/subpath) → verbatim. - Relative path (
./plugins/mytool.mjs) → resolved against the workspace root as afile:URL.
Bucket assignment (languages / frameworks / effects in aburi.json) is still enforced at load time: if the loaded manifest's type does not match the bucket it was listed under, the loader throws with a plugin-error (CliError → EXIT.GATE).
Your module must export the plugin as a default export, a plugin export, or any top-level export whose value has a manifest field. The first hit wins.
Testing
- Unit-test your plugin in isolation — see
packages/framework-nestjs/testfor the pattern (fakeExtractionContext, hand-authoredSymbolCandidates). - Snapshot-verify the manifest against
schema/aburi.plugin.v1.json— reuse the existing schema validation helpers in@aburi/plugin-registry. - Wire the plugin into
packages/e2e-integrationfor an integration pass against a small handwritten fixture project.
Publishing
- Namespace under
@aburi/*if first-party. Third-party plugins can use any scope (or unscoped) — the loader accepts both. - Follow the workspace tsdown / tsconfig config so the plugin ships ESM (
.mjs+.d.mts). - Include
manifestin the top-level exports so the loader's fallback hunt finds it.