Skip to content

Commit 6622f58

Browse files
feat: native ast generalization (#46)
* feat(ast): self-describing plugins, registry discovery, all/none/explicit selection Add an optional describe()->i64 export (JSON {languageId, extensions}) that the host reads into LoadedPlugin.metadata, making modules fully self-describing alongside contractVersion() and parse* capability probes. - types: NativeLang widened to string; add PluginMetadata. - plugin-host: read describe() during bindExports; listPluginFiles globs the template-guarded ^codesight-(<lang>)-ast.wasm$ across the dir waterfall. - native-loader: buildNativeRegistry maps extension -> plugin (describe-wins languageId, DEFAULT_EXTENSIONS fallback, first-match precedence, collision rule); NativeAstResolved gains authoritative + warnings. - index: --native-ast[=ids|all|none] selection (drops KNOWN_NATIVE_LANGS). - reference plugin: export describe(); rebuild wasm + checksums. Foundation only: built-in in-detector dispatch is still live, so behavior is unchanged. * feat(detectors): language-driven generic native pass; migrate off in-detector dispatch Introduce src/detectors/native.ts as the single point where WASM plugins are dispatched: group files by extension via the registry, call parseRoutes/ parseSchemas, stamp confidence "native" (framework "unknown" interim), and record strict diagnostics. mergeNativeRoutes is native-preferred-dedup (additive) or per-file replace (authoritative, with an empty-with-builtin warning); mergeNativeSchemas dedups by name (SchemaModel lacks file provenance). - routes/schema: drop the per-detector nativePluginFor calls; built-in extractors remain as pure fallback. Export detectTags for the pass. - core: run detectNative after the built-in detectors and merge. - tests: native-ast dispatch tests move to the generic pass (detectNative). * test(ast): cover describe() metadata, registry routing, and the generic pass Add a generalization suite to reference-plugin.test.ts: reads describe() metadata off the loaded plugin, asserts buildNativeRegistry routes the declared extension (explicit => authoritative), drives the generic pass over a .ref file, and verifies `all` mode discovers the plugin by globbing the plugin dir (=> additive). * docs: document language-driven dispatch, describe(), and WASI instantiation Rework the plugin contract for the generalization: any user-declared language, describe()-driven discovery (id/extensions, template guard, precedence/collision), all/none/=ids enabling with additive-vs-authoritative merge, and the WASI instantiation row in the ABI.
1 parent ffce544 commit 6622f58

14 files changed

Lines changed: 613 additions & 268 deletions

File tree

docs/wasm-plugins.md

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -39,73 +39,86 @@ The module is instantiated once per scan and its `parse*` functions are called m
3939
times (it's a long-lived "reactor", not a per-file process). Compile a library, not
4040
a command.
4141

42-
> **Scope:** `codesight` invokes a plugin only at its own extraction points and only
43-
> for a language identifier it recognizes. It provides no WASM-based parsers itself.
44-
> Where no plugin is configured, it falls back to its built-in extraction. This contract
45-
> defines the boundary; it does not promise any particular language is wired up.
42+
> **Scope:** `codesight` ships no parsers itself. Dispatch is **language-driven**: a
43+
> plugin declares the file extensions it handles (via `describe()`, below), and
44+
> `codesight` routes matching files to it — so *any* language works, not just the ones
45+
> with built-in extractors. Where no plugin handles a file, built-in extraction stands.
4646
4747
---
4848

4949
## Discovery & naming
5050

51-
A plugin is identified by a language identifier `<lang>`. Per identifier,
52-
`codesight` looks for a single self-describing module — no sidecar manifest:
51+
Plugin binaries must match the template (anything else on the path is ignored):
5352

5453
```
55-
codesight-<lang>-ast.wasm # the module (required)
54+
codesight-<lang>-ast.wasm # `<lang>` ∈ [a-z0-9_-]+ ; the `-ast` capability namespace is reserved
5655
```
5756

58-
The `-ast` segment is the capability namespace (reserved to allow the same plugin
59-
mechanism to host other capabilities later if/when it becomes beneficial or desirable
60-
to do so). The module declares its own contract version and capabilities through
61-
its exports (see below), so there is nothing else to ship or keep in sync.
57+
The module is **fully self-describing** — no sidecar manifest. Its `describe()`
58+
export (below) declares the authoritative `languageId` and the file `extensions`
59+
it parses; the `<lang>` in the filename is only a discovery key + fallback id (if
60+
`describe().languageId` is set and differs, the declared id wins, with a warning).
61+
For the built-in language ids (`rust`/`go`/`python`) a default extension map
62+
(`.rs`/`.go`/`.py`) applies when `describe()` is omitted; **any other language must
63+
declare `extensions`** or it can't be routed.
6264

63-
Directories are searched in this order; the **first** directory containing a
64-
matching `.wasm` binary wins (\*nix `PATH`-style waterfall):
65+
Directories are searched in PATH-style waterfall order:
6566

6667
1. `--plugin-dir <dir>` / `CODESIGHT_PLUGIN_DIR` (relative paths resolve against the project root)
6768
2. `~/.codesight/plugins`
6869
3. `${XDG_DATA_HOME:-~/.local/share}/codesight/plugins`
6970
4. `<codesight install dir>/plugins`
7071

72+
**Precedence:** per language id, the first dir wins (lower dirs shadowed). If two
73+
*different* languages claim the same extension, an explicitly-named language beats
74+
an `all`-discovered one, else first-registered wins — with a warning either way.
75+
7176
---
7277

7378
## Enabling native parsing
7479

7580
Native parsing is off unless explicitly enabled. Precedence is `CLI` > `env` > `config file`.
7681

77-
| Mechanism | Example |
78-
|-----------|---------------------------------------------------------------------------------------------------------------------------------|
79-
| CLI | `codesight --native-ast` · `codesight --native-ast <langs>` · `codesight --native-ast-strict` · `codesight --plugin-dir ./wasm` |
80-
| Env | `CODESIGHT_NATIVE_AST=1` (or `strict`, or a comma list of language identifiers) · `CODESIGHT_PLUGIN_DIR=/path` |
81-
| Config | `codesight.config.{json,js,ts}``{ "nativeAst": { "enabled": true, "languages": ["<lang>"], "pluginDir": "./wasm" } }` |
82-
83-
`enabled` may be `true`, `false`, or `"strict"`. Omitting `languages` (or passing
84-
an empty list) enables every language identifier `codesight` recognizes; supplying a
85-
list restricts native parsing to those identifiers.
82+
| Mechanism | Example |
83+
|-----------|------------------------------------------------------------------------------------------------------------------------------------|
84+
| CLI | `codesight --native-ast` (= all) · `codesight --native-ast=rust,go` · `codesight --native-ast=none` · `codesight --native-ast-strict` · `codesight --plugin-dir ./wasm` |
85+
| Env | `CODESIGHT_NATIVE_AST=all` (or `1`/`true`/`strict`/`none`, or a comma list of ids) · `CODESIGHT_PLUGIN_DIR=/path` |
86+
| Config | `codesight.config.{json,js,ts}``{ "nativeAst": { "enabled": true, "languages": ["rust"], "pluginDir": "./wasm" } }` |
87+
88+
`enabled` may be `true`, `false`, or `"strict"`. `none` forces off (overriding the
89+
config file). **Dispatch mode depends on how you enable it:**
90+
91+
- **`all` / bare flag / empty `languages`***additive*: every discovered plugin is
92+
consulted, and results are **unioned** with the built-in extractors (native wins on
93+
a `method:path` / model-name conflict).
94+
- **An explicit language list** (`--native-ast=rust,go`) → *authoritative*: for files
95+
a named plugin handles, its results **replace** the built-in routes from that file
96+
(dropping regex over-matches). If the plugin returns nothing for a file the built-in
97+
did extract, the built-in stands and a warning is emitted. (Authoritative replacement
98+
is route-only — `SchemaModel` carries no file provenance, so schemas are always
99+
native-preferred dedup-by-name.)
86100

87101
---
88102

89103
## The WASM ABI
90104

91105
The host instantiates every module with a **minimal WASI import object** (Node's
92106
built-in `node:wasi` — clock/random/exit/stderr only; **no filesystem, no network,
93-
no env/args**). Pure-compute modules with no imports (Rust/AssemblyScript on
94-
`wasm32-unknown-unknown`) ignore it; modules whose runtime needs WASI (e.g. Go
95-
`//go:wasmexport` reactors) get exactly those minimal capabilities. A module that
96-
exports `_initialize` is initialized before any other export is called. The module
97-
must not require JS-binding glue or host functions beyond WASI. `node:wasi` is built
98-
in, so codesight keeps its zero-dependency guarantee.
107+
no env/args**). Pure-compute modules (Rust/AssemblyScript, no imports) ignore it;
108+
modules whose runtime needs WASI (e.g. Go `//go:wasmexport` reactors) get exactly
109+
those minimal capabilities. A reactor that exports `_initialize` is initialized
110+
before its other exports are called. "Plugins are pure compute" — if a kind ever
111+
needs project context, it flows through the ABI, not the filesystem.
99112

100-
A conforming module exports a small fixed core plus one optional `parse*` function
101-
per capability it provides:
113+
A conforming module exports a small fixed core plus optional capability functions:
102114

103115
| Export | Signature (wasm types) | Purpose |
104116
|-------------------|---------------------------------------|-------------------------------------------------|
105117
| `memory` | linear memory | the module's exported memory |
106118
| `alloc` | `(len: i32) -> i32` | reserve `len` bytes, return a pointer |
107119
| `dealloc` | `(ptr: i32, len: i32) -> ()` | release a prior allocation |
108120
| `contractVersion` | `() -> i32` | the contract version this plugin implements |
121+
| `describe` | `() -> i64` | *optional* — packed JSON metadata (see below) |
109122
| `parseRoutes` | `(srcPtr: i32, srcLen: i32) -> i64` | *optional* — extract routes |
110123
| `parseSchemas` | `(srcPtr: i32, srcLen: i32) -> i64` | *optional* — extract schema models |
111124
| `parseImports` | `(srcPtr: i32, srcLen: i32) -> i64` | *optional* — extract imports (defined, not yet dispatched — see below) |
@@ -116,6 +129,20 @@ also makes `contractVersion` a "this is a codesight plugin" marker. **Capability
116129
detected by export presence:** a plugin supports a kind iff it exports the matching
117130
`parse*` function. There are no kind codes and no manifest.
118131

132+
### `describe()` — self-description
133+
134+
Optional. Returns packed `(outPtr << 32) | outLen` pointing at UTF-8 JSON (same
135+
convention as `parse*`), e.g.:
136+
137+
```jsonc
138+
{ "languageId": "ruby", "extensions": [".rb", ".rake"] }
139+
```
140+
141+
The host reads `languageId` (authoritative over the filename) and `extensions` (how
142+
files are routed to this plugin); other fields are carried but unused for now. A
143+
plugin for a non-built-in language **must** declare `extensions` here, or it has no
144+
files to receive.
145+
119146
As WebAssembly text:
120147

121148
```wat
@@ -286,10 +313,10 @@ from built-in `ast`/`regex` results in the scan summary.
286313
Implement the module in any toolchain that targets `wasm32` and imports **at most
287314
`wasi_snapshot_preview1`** (no JS-binding glue, no other host functions). Pure-compute
288315
languages (Rust/AssemblyScript via `wasm32-unknown-unknown`) need no imports at all;
289-
full-runtime languages (Go via `GOOS=wasip1 -buildmode=c-shared`) import WASI, which
290-
the host supplies minimally. The allocator and the per-kind marshalling are
291-
boilerplate; the only part that changes is your extraction logic. Export only the
292-
`parse*` functions for the kinds you support.
316+
full-runtime languages (Go via `GOOS=wasip1 -buildmode=c-shared` + `//go:wasmexport`)
317+
import WASI, which the host supplies minimally. The allocator and per-kind marshalling
318+
are boilerplate; only your extraction logic changes. Export `describe()` (for routing)
319+
plus the `parse*` functions for the kinds you support.
293320

294321
Required exports and their behavior, in pseudocode:
295322

reference/ast-plugin/assembly/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export function contractVersion(): i32 {
2626
return 1;
2727
}
2828

29+
/** Optional self-description: language id + the extensions this plugin parses. */
30+
export function describe(): i64 {
31+
return emit("{\"languageId\":\"reference\",\"extensions\":[\".ref\"]}");
32+
}
33+
2934
// ─── ABI: memory management ───
3035

3136
export function alloc(len: i32): i32 {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"algorithm": "sha256",
33
"files": {
4-
"assembly/index.ts": "4d2f5917df6f2ca85c33fa59b390a4a87331812a0a2522528aa9c3a35d7b2229",
5-
"codesight-reference-ast.wasm": "31c49dfa343a8fec0578c062a7f5bac013260de3e1852551a020ebc278b3b843"
4+
"assembly/index.ts": "7dbd49e902cbc8c51a7aa19010a4893f1aab7c0ddba32ad5219cbfe988545ee3",
5+
"codesight-reference-ast.wasm": "a8a0429b4a72f6937d26d455b60593b7aa8593a937c65c7d1f18dda20af484ad"
66
}
77
}
140 Bytes
Binary file not shown.

src/ast/native-loader.ts

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,25 @@ import type {
2323
ORM,
2424
Framework,
2525
} from "../types.js";
26-
import { loadPlugin } from "../wasm/plugin-host.js";
26+
import { loadPlugin, listPluginFiles, type LoadedPlugin } from "../wasm/plugin-host.js";
2727

2828
/** Effective, resolved native-AST settings for one scan. */
2929
export interface NativeAstResolved {
3030
mode: false | "on" | "strict";
31-
/** Languages native parsing applies to. Empty set = all languages. */
31+
/** Languages native parsing applies to. Empty set = all discovered languages. */
3232
languages: Set<NativeLang>;
33+
/**
34+
* True when an explicit language list was given (so those languages are
35+
* AUTHORITATIVE — they preempt built-in extraction). Empty list / "all" =
36+
* additive. Equivalent to `languages.size > 0`, captured for clarity.
37+
*/
38+
authoritative: boolean;
3339
/** Plugin directories searched in order (PATH-style waterfall). */
3440
pluginDirs: string[];
3541
/** Shared, mutable sink for strict-mode diagnostics. */
3642
diagnostics: NativeDiagnostic[];
43+
/** Shared, mutable sink for non-fatal warnings (collisions, name mismatch, etc.). */
44+
warnings: string[];
3745
}
3846

3947
/** A plugin adapted to codesight's domain types. Methods return null when the
@@ -47,8 +55,18 @@ export interface NativePlugin {
4755
const DISABLED: NativeAstResolved = {
4856
mode: false,
4957
languages: new Set(),
58+
authoritative: false,
5059
pluginDirs: [],
5160
diagnostics: [],
61+
warnings: [],
62+
};
63+
64+
/** Built-in default extensions for the historically-known language ids, used when
65+
* a plugin omits `describe().extensions`. New languages must self-report. */
66+
const DEFAULT_EXTENSIONS: Record<string, string[]> = {
67+
rust: [".rs"],
68+
go: [".go"],
69+
python: [".py"],
5270
};
5371

5472
// Memoized by the `nativeAst` config object reference. scan() passes the same
@@ -70,11 +88,14 @@ export function resolveNativeAst(
7088
const cached = resolvedCache.get(cfg);
7189
if (cached) return cached;
7290

91+
const languages = new Set(cfg.languages ?? []);
7392
const resolved: NativeAstResolved = {
7493
mode: cfg.enabled === "strict" ? "strict" : "on",
75-
languages: new Set(cfg.languages ?? []),
94+
languages,
95+
authoritative: languages.size > 0,
7696
pluginDirs: defaultPluginDirs(projectRoot, cfg.pluginDir),
7797
diagnostics: [],
98+
warnings: [],
7899
};
79100
resolvedCache.set(cfg, resolved);
80101
return resolved;
@@ -108,6 +129,94 @@ export function isStrict(r: NativeAstResolved): boolean {
108129
return r.mode === "strict";
109130
}
110131

132+
/** A language's adapted plugin + whether it preempts built-in extraction. */
133+
export interface NativeRegistryEntry {
134+
lang: string;
135+
authoritative: boolean;
136+
plugin: NativePlugin;
137+
}
138+
139+
/** Extension → owning plugin, for one scan. */
140+
export interface NativeRegistry {
141+
/** Lowercased extension (leading dot) → the plugin that handles it. */
142+
byExt: Map<string, NativeRegistryEntry>;
143+
}
144+
145+
/**
146+
* Build the extension→plugin registry: target the explicit languages (or
147+
* enumerate all discovered plugins for "all"), resolve each language's
148+
* identity (`describe().languageId` wins over the filename) and extensions
149+
* (`describe().extensions`, else the built-in default map), and route extensions
150+
* with the collision rule. Records strict diagnostics for enabled-but-unavailable
151+
* or unroutable languages, and warnings for name mismatches / extension collisions.
152+
*/
153+
export function buildNativeRegistry(r: NativeAstResolved): NativeRegistry {
154+
const byExt = new Map<string, NativeRegistryEntry>();
155+
if (r.mode === false) return { byExt };
156+
157+
const candidates = r.authoritative
158+
? [...r.languages]
159+
: listPluginFiles(r.pluginDirs).map((f) => f.lang);
160+
161+
for (const requested of candidates) {
162+
const loaded = loadPlugin(requested, r.pluginDirs);
163+
if (!loaded) {
164+
if (r.mode === "strict") recordUnavailableLang(r, requested);
165+
continue;
166+
}
167+
168+
// `describe().languageId` is authoritative for identity; filename is fallback.
169+
const reported = loaded.metadata?.languageId;
170+
const lang = reported && reported.length > 0 ? reported : requested;
171+
if (reported && reported.length > 0 && reported !== requested) {
172+
addWarning(
173+
r,
174+
`plugin "codesight-${requested}-ast.wasm" self-reports languageId "${reported}" — honoring "${reported}" (rename the file to match for explicit --native-ast=${reported})`
175+
);
176+
// In explicit mode the user asked for `requested`; a file claiming a
177+
// different id doesn't satisfy that request.
178+
if (r.authoritative && !r.languages.has(lang)) {
179+
if (r.mode === "strict") recordUnavailableLang(r, requested);
180+
continue;
181+
}
182+
}
183+
184+
const declared = loaded.metadata?.extensions;
185+
const exts = (declared && declared.length > 0 ? declared : DEFAULT_EXTENSIONS[lang]) ?? [];
186+
if (exts.length === 0) {
187+
if (r.mode === "strict") {
188+
r.diagnostics.push({
189+
lang,
190+
kind: "routes",
191+
reason: "no extensions declared (describe().extensions is required for non-built-in languages)",
192+
});
193+
}
194+
continue;
195+
}
196+
197+
const entry: NativeRegistryEntry = { lang, authoritative: r.authoritative, plugin: adaptPlugin(loaded) };
198+
for (const raw of exts) {
199+
const ext = (raw.startsWith(".") ? raw : "." + raw).toLowerCase();
200+
const existing = byExt.get(ext);
201+
if (!existing || existing.lang === entry.lang) {
202+
byExt.set(ext, entry);
203+
continue;
204+
}
205+
const winner = resolveCollision(existing, entry);
206+
byExt.set(ext, winner);
207+
addWarning(r, `extension "${ext}" claimed by both "${existing.lang}" and "${entry.lang}" — using "${winner.lang}"`);
208+
}
209+
}
210+
211+
return { byExt };
212+
}
213+
214+
/** Collision tiebreak: explicit (authoritative) beats discovered; else first-registered wins. */
215+
function resolveCollision(existing: NativeRegistryEntry, candidate: NativeRegistryEntry): NativeRegistryEntry {
216+
if (candidate.authoritative && !existing.authoritative) return candidate;
217+
return existing;
218+
}
219+
111220
/**
112221
* Get the native plugin for (lang, kind), or null when native is disabled for
113222
* the language, no plugin is available, or the plugin doesn't support the kind.
@@ -162,6 +271,17 @@ function recordUnavailable(r: NativeAstResolved, lang: NativeLang, kind: NativeK
162271
if (!dup) r.diagnostics.push({ lang, kind, reason: "plugin unavailable" });
163272
}
164273

274+
/** Language-level "no plugin found" diagnostic (deduped per language). */
275+
function recordUnavailableLang(r: NativeAstResolved, lang: NativeLang): void {
276+
const dup = r.diagnostics.some((d) => d.lang === lang && !d.file && d.reason === "plugin unavailable");
277+
if (!dup) r.diagnostics.push({ lang, kind: "routes", reason: "plugin unavailable" });
278+
}
279+
280+
/** Append a non-fatal warning (deduped). */
281+
function addWarning(r: NativeAstResolved, message: string): void {
282+
if (!r.warnings.includes(message)) r.warnings.push(message);
283+
}
284+
165285
/**
166286
* Build a domain-typed adapter around the raw plugin, or null if unavailable.
167287
* A method is exposed only when the plugin exports the matching capability, so
@@ -170,7 +290,12 @@ function recordUnavailable(r: NativeAstResolved, lang: NativeLang, kind: NativeK
170290
function getNativePlugin(lang: NativeLang, r: NativeAstResolved): NativePlugin | null {
171291
const loaded = loadPlugin(lang, r.pluginDirs);
172292
if (!loaded) return null;
293+
return adaptPlugin(loaded);
294+
}
173295

296+
/** Map a raw LoadedPlugin to codesight's domain types. A method is exposed only
297+
* when the plugin exports the matching capability (so callers can detect it). */
298+
function adaptPlugin(loaded: LoadedPlugin): NativePlugin {
174299
const np: NativePlugin = {};
175300

176301
if (loaded.routes) {

0 commit comments

Comments
 (0)