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:
Colby Mchenry
2026-07-06 12:41:32 -05:00
committed by GitHub
co-authored by Tyce Herrman Claude Fable 5
parent 99152212a9
commit 7f325134e0
17 changed files with 1262 additions and 7 deletions
+178
View File
@@ -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 = 'submodule';
const decls = new Map<string, Rec[]>();
const writes: Rec[] = [];
const register = (path: string[], rec: Rec) => {
if (path.length < 2 || path.includes(SUBMODULE)) return;
const key = path.join('.');
const arr = decls.get(key);
if (arr) arr.push(rec);
else decls.set(key, [rec]);
};
for (const recs of byFile.values()) {
recs.sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine);
const stack: Array<{ start: number; end: number; prefix: string[] }> = [];
for (const rec of recs) {
while (stack.length > 0 && stack[stack.length - 1]!.end < rec.startLine) stack.pop();
// Strict containment at line granularity: a one-line nested binding is
// indistinguishable from its container, so it stays unclassified (rare
// in module code, where option blocks are multi-line).
const enclosing =
stack.length > 0 &&
rec.startLine >= stack[stack.length - 1]!.start &&
rec.endLine <= stack[stack.length - 1]!.end &&
!(rec.startLine === stack[stack.length - 1]!.start && rec.endLine === stack[stack.length - 1]!.end)
? stack[stack.length - 1]!
: null;
if (rec.segs[0] === 'options') {
const ownPath = rec.segs.slice(1); // [] for the bare `options = { ... }` spelling
const prefix = enclosing ? [SUBMODULE] : ownPath;
register(prefix, rec);
stack.push({ start: rec.startLine, end: rec.endLine, prefix });
continue;
}
if (enclosing) {
const composed = [...enclosing.prefix, ...rec.segs];
register(composed, rec);
stack.push({ start: rec.startLine, end: rec.endLine, prefix: composed });
continue;
}
if (rec.segs.length >= 2) {
writes.push(rec);
}
}
}
if (decls.size === 0 || writes.length === 0) return [];
const edges: Edge[] = [];
for (const w of writes) {
// `config.services.x = ...` spells the same write with an explicit prefix.
const segs = w.segs[0] === 'config' ? w.segs.slice(1) : w.segs;
if (segs.length < 2) continue;
// Longest prefix wins; an ambiguous longest match does NOT fall back to a
// shorter one (that would link `services.nginx.virtualHosts.x` to
// `options.services.nginx` when virtualHosts is the contested path).
for (let len = Math.min(segs.length, 6); len >= 2; len--) {
const candidates = decls.get(segs.slice(0, len).join('.'));
if (!candidates || candidates.length === 0) continue;
const files = new Set(candidates.map((c) => c.filePath));
if (files.size === 1) {
const target = candidates[0]!;
if (target.id !== w.id) {
edges.push({
source: w.id,
target: target.id,
kind: 'references',
line: w.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'nix-option-path',
optionPath: segs.slice(0, len).join('.'),
registeredAt: `${target.filePath}:${target.startLine}`,
},
});
}
}
break; // longest hit decides, matched or ambiguous
}
}
return edges;
}
function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
// Cheap language gate: no Erlang modules → no cost beyond one kind query.
const erlangModules = queries.getNodesByKind('namespace').filter((n) => n.language === 'erlang');
@@ -3177,6 +3353,7 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
const laravelEdges = laravelEventEdges(ctx); await yieldToLoop();
const cFnPtrEdges = cFnPointerDispatchEdges(queries, ctx); await yieldToLoop();
const goframeEdges = goframeRouteEdges(ctx); await yieldToLoop();
const nixOptionEdges = await nixOptionPathEdges(queries, yieldToLoop); await yieldToLoop();
const merged: Edge[] = [];
const seen = new Set<string>();
@@ -3216,6 +3393,7 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
...laravelEdges,
...cFnPtrEdges,
...goframeEdges,
...nixOptionEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;
+34
View File
@@ -40,8 +40,18 @@ const EXTENSION_RESOLUTION: Record<string, string[]> = {
php: ['.php'],
ruby: ['.rb'],
objc: ['.h', '.m', '.mm'],
nix: ['.nix', '/default.nix'],
};
export function isNixPathImportRef(ref: UnresolvedRef): boolean {
return (
ref.language === 'nix' &&
ref.referenceKind === 'imports' &&
(ref.referenceName.startsWith('./') || ref.referenceName.startsWith('../')) &&
!/[\s{}()[\];"'<>$]/.test(ref.referenceName)
);
}
/**
* Resolve an import path to an actual file
*/
@@ -1292,6 +1302,30 @@ export function resolveViaImport(
return null;
}
// Nix static project-path imports (`import ./x.nix`, `builtins.import ./dir`,
// `import ./x.nix {}`) resolve to file nodes only. Do not resolve
// angle-bracket channels, attribute expressions, variables, or other dynamic
// expressions as project files.
if (isNixPathImportRef(ref)) {
const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
if (!resolvedPath) return null;
const basename = resolvedPath.split('/').pop()!;
const fileNode = context
.getNodesByName(basename)
.find((n) => n.kind === 'file' && n.filePath === resolvedPath);
if (fileNode) {
return {
original: ref,
targetNodeId: fileNode.id,
confidence: 0.9,
resolvedBy: 'import',
};
}
return null;
}
// Use cached import mappings (avoids re-reading and re-parsing per ref)
const imports = context.getImportMappings(ref.filePath, ref.language);
if (imports.length === 0 && !context.readFile(ref.filePath)) {
+28 -3
View File
@@ -17,7 +17,7 @@ import {
ImportMapping,
} from './types';
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef } from './import-resolver';
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef } from './import-resolver';
import { detectFrameworks } from './frameworks';
import { synthesizeCallbackEdges } from './callback-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
@@ -747,11 +747,14 @@ export class ReferenceResolver {
// ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that
// routes them to the decorator-gated matcher; the symbol itself is
// indexed under the bare name, so the existence check strips the dot.
// Nix static path imports (`import ./x.nix`) name a FILE, not a symbol —
// they bypass the symbol-existence check and resolve via resolveViaImport.
const existenceName =
ref.language === 'arkts' && ref.referenceName.startsWith('.')
? ref.referenceName.slice(1)
: ref.referenceName;
if (
!isNixPathImportRef(ref) &&
!this.hasAnyPossibleMatch(existenceName) &&
!this.matchesAnyImport(ref) &&
!this.frameworks.some((f) => f.claimsReference?.(ref.referenceName))
@@ -826,7 +829,9 @@ export class ReferenceResolver {
// framework resolver IS the whole rulebook (`var.X` can never legally
// bind outside its module directory), so the name-matcher's
// qualified-name fallback would only ever add wrong cross-module edges.
if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || ref.language === 'terraform') {
// Nix static path imports are file references for the same reason —
// falling through would let "./x.nix" name-match an unrelated node.
if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || ref.language === 'terraform') {
return candidates.length > 0
? candidates.reduce((best, curr) =>
curr.confidence > best.confidence ? curr : best
@@ -835,7 +840,27 @@ export class ReferenceResolver {
}
// Strategy 3: Try name matching
const nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
let nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
// Nix has no ambient cross-file namespace — a callee binds lexically
// (same file) or through explicit import/callPackage wiring (the import
// path above). A cross-file name match is wrong by construction: every
// module `inherit (lib) mkOption`s the same nixpkgs helpers, so the
// matcher would link each `mkOption` call to whichever file's inherit
// binding it happened to pick. Same-file matches only.
if (nameResult) {
const target = this.queries.getNodeById(nameResult.targetNodeId);
if (ref.language === 'nix') {
if (!target || target.filePath !== ref.filePath) {
nameResult = null;
}
} else if (target && target.language === 'nix') {
// The reverse direction is just as impossible: no other language can
// symbolically call into a .nix binding (interop is eval/CLI, never a
// linkable symbol) — without this, a Python script's `split()` lands
// on some module's `split = ...` binding as a low-confidence match.
nameResult = null;
}
}
if (nameResult) {
candidates.push(nameResult);
}