feat(extraction): add Nix language support with module-system option wiring (#324, #332 via #648 — carries #1084) (#1190)
Carries @TyceHerrman's #1084 as the functional base. Extraction + file wiring (imports/modules lists, callPackage), module-system option-path synthesizer, lexical-scope resolution gates, ABI-15 wasm rebuilt from upstream source. Validated on agenix, nix-darwin, home-manager, and nixpkgs (44,368 files, 3m49s, 1.30M nodes). Co-authored-by: Tyce Herrman <Tyce.Herrman@pm.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Tyce Herrman
Claude Fable 5
parent
99152212a9
commit
7f325134e0
@@ -2854,6 +2854,182 @@ function erlangArityAt(src: string, openIdx: number): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nix module-system option wiring. A NixOS/home-manager/nix-darwin option is
|
||||
* DECLARED in one module (`options.launchd.user.agents = mkOption { ... }`)
|
||||
* and SET in others (`launchd.user.agents.yabai = { ... }` inside a module's
|
||||
* config) — the connection happens by option-path unification inside the
|
||||
* module-system evaluator, so there is no static call/import edge to follow
|
||||
* and flow questions ("how does services.yabai.enable become a launchd
|
||||
* service?") go dark at the module boundary.
|
||||
*
|
||||
* This pass links each config-write binding to the option declaration whose
|
||||
* path is the longest static-segment prefix of the write path. Precision gates:
|
||||
* - only STATIC segments participate: plain identifiers, plus quoted segments
|
||||
* (`"git/config"`, `"com.apple.dock"`) as opaque verbatim tokens that match
|
||||
* only quote-exactly; an interpolated (`${name}`) segment ends the prefix,
|
||||
* so dynamic paths never match beyond their static head;
|
||||
* - matched prefixes must be ≥2 segments: 1-segment paths would wrongly link
|
||||
* every package's `meta = { ... }` attrset to nixos's `options.meta`;
|
||||
* - a prefix declared in more than one file is ambiguous → no edge (a wrong
|
||||
* edge is worse than none);
|
||||
* - writes physically inside an options block are declaration internals
|
||||
* (types, defaults, examples), never config writes → excluded.
|
||||
* Both declaration spellings register: flat (`options.a.b = ...`) by name, and
|
||||
* nested (`options = { a.b = ...; }`) by line-span containment.
|
||||
*/
|
||||
function nixLeadingPlainSegments(name: string): string[] {
|
||||
const segs: string[] = [];
|
||||
let i = 0;
|
||||
const n = name.length;
|
||||
while (i < n) {
|
||||
if (name[i] === '"') {
|
||||
// Quoted segment — an opaque verbatim token (quotes kept, so it can
|
||||
// never collide with a plain identifier). `NSGlobalDomain."com.apple.
|
||||
// mouse.tapBehavior"` must match ITS OWN quoted declaration, not
|
||||
// whichever sibling registered the shared plain prefix first.
|
||||
let j = i + 1;
|
||||
while (j < n && name[j] !== '"') {
|
||||
if (name[j] === '\\') j++;
|
||||
j++;
|
||||
}
|
||||
if (j >= n) return segs; // unterminated — stop at the static head
|
||||
const tok = name.slice(i, j + 1);
|
||||
if (tok.includes('${')) return segs; // interpolated → dynamic → stop
|
||||
segs.push(tok);
|
||||
i = j + 1;
|
||||
if (i >= n) break;
|
||||
if (name[i] !== '.') return segs;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < n && name[j] !== '.') {
|
||||
if (name[j] === '"' || (name[j] === '$' && name[j + 1] === '{')) return segs;
|
||||
j++;
|
||||
}
|
||||
const seg = name.slice(i, j);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_'-]*$/.test(seg)) return segs;
|
||||
segs.push(seg);
|
||||
i = j + 1;
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
async function nixOptionPathEdges(queries: QueryBuilder, onYield: MaybeYield): Promise<Edge[]> {
|
||||
type Rec = { id: string; filePath: string; startLine: number; endLine: number; segs: string[] };
|
||||
|
||||
// One streaming pass over nix bindings (variables + the odd function-valued
|
||||
// option); memory stays O(bindings-kept), not O(all nodes) (#610).
|
||||
const byFile = new Map<string, Rec[]>();
|
||||
let scanned = 0;
|
||||
for (const kind of ['variable', 'function'] as NodeKind[]) {
|
||||
for (const node of queries.iterateNodesByKind(kind)) {
|
||||
if ((++scanned & 0x3fff) === 0 && onYield) await onYield();
|
||||
if (node.language !== 'nix') continue;
|
||||
const segs = nixLeadingPlainSegments(node.name);
|
||||
if (segs.length === 0) continue;
|
||||
const rec: Rec = {
|
||||
id: node.id,
|
||||
filePath: node.filePath,
|
||||
startLine: node.startLine,
|
||||
endLine: node.endLine,
|
||||
segs,
|
||||
};
|
||||
const arr = byFile.get(node.filePath);
|
||||
if (arr) arr.push(rec);
|
||||
else byFile.set(node.filePath, [rec]);
|
||||
}
|
||||
}
|
||||
|
||||
// Per file: walk bindings outermost-first with a stack of active option
|
||||
// spans, composing nested declaration paths (`options = { services.foo = {
|
||||
// enable = mkOption ...; }; }` registers services.foo AND services.foo.enable).
|
||||
// An `options` binding nested inside another option span is a SUBMODULE's
|
||||
// own namespace (`attrsOf (submodule { options = ...; })`) — its internals
|
||||
// are not globally addressable, so the sentinel blocks registration below it
|
||||
// while still excluding the region from write candidates.
|
||||
const SUBMODULE = ' | ||||