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
+146
View File
@@ -144,6 +144,12 @@ describe('Language Detection', () => {
expect(detectLanguage('entry/src/main/ets/common/utils.ts')).toBe('typescript');
});
it('should detect Nix files', () => {
expect(detectLanguage('default.nix')).toBe('nix');
expect(detectLanguage('pkgs/development/tools/misc/codegraph/default.nix')).toBe('nix');
expect(isSourceFile('default.nix')).toBe(true);
});
it('should return unknown for unsupported extensions', () => {
expect(detectLanguage('styles.css')).toBe('unknown');
expect(detectLanguage('data.json')).toBe('unknown');
@@ -173,6 +179,146 @@ describe('Language Support', () => {
expect(languages).toContain('kotlin');
expect(languages).toContain('dart');
expect(languages).toContain('solidity');
expect(languages).toContain('nix');
});
});
describe('Nix Extraction', () => {
it('should distinguish Nix variable and function bindings', () => {
const code = `
let
plainValue = 10;
simpleFn = arg: arg + 1;
destructuredFn = { lib, stdenv }: lib.getName stdenv;
curriedFn = a: b: builtins.toString (a + b);
in
{
exportedValue = plainValue;
exportedFn = curriedFn;
}
`;
const result = extractFromSource('default.nix', code);
expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'plainValue')).toBeDefined();
expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'exportedValue')).toBeDefined();
const simpleFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'simpleFn');
const destructuredFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'destructuredFn');
const curriedFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'curriedFn');
expect(simpleFn?.signature).toBe('(arg)');
expect(destructuredFn?.signature).toBe('{ lib, stdenv }');
expect(curriedFn?.signature).toBe('a : b');
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toContain('lib.getName');
expect(calls.filter((name) => name === 'builtins.toString')).toHaveLength(1);
});
it('should extract inherited Nix attributes as variables', () => {
const code = `
let
inherit lib;
inherit (pkgs) stdenv writeShellScriptBin;
in
stdenv.mkDerivation {}
`;
const result = extractFromSource('default.nix', code);
const variables = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
expect(variables).toContain('lib');
expect(variables).toContain('stdenv');
expect(variables).toContain('writeShellScriptBin');
});
it('should emit only static project path imports for Nix import calls', () => {
const code = `
let
local = import ./x.nix;
defaultFile = builtins.import ./dir;
packageSet = import <nixpkgs> {};
fromSources = import sources.nixpkgs {};
dynamic = import selectedPath;
in
local
`;
const result = extractFromSource('default.nix', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName);
expect(imports).toEqual(['./x.nix', './dir']);
expect(importRefs).toEqual(['./x.nix', './dir']);
});
it('should emit file imports for NixOS module imports/modules lists (literal paths only)', () => {
const code = `
{ config, lib, ... }:
{
imports = [ ./hardware.nix ../common inputs.foo.nixosModules.bar ];
home-manager.users.demo.imports = [ ./home.nix ];
flake.modules = [ ./configuration.nix ];
notAModuleList = [ ./ignored.nix ];
}
`;
const result = extractFromSource('configuration.nix', code);
const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName);
expect(importRefs).toEqual(['./hardware.nix', '../common', './home.nix', './configuration.nix']);
// The dynamic entry (inputs.foo.nixosModules.bar) must not create a ref.
expect(importRefs).not.toContain('inputs.foo.nixosModules.bar');
});
it('should emit file imports for callPackage with a literal path and skip dynamic ones', () => {
const code = `
{ pkgs, newScope }:
let
hello = pkgs.callPackage ./pkgs/hello { };
tools = pkgs.callPackages ../tools/all.nix { };
dynamic = pkgs.callPackage pkgPath { };
in
{
inherit hello tools dynamic;
}
`;
const result = extractFromSource('overlay.nix', code);
const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(importRefs).toEqual(['./pkgs/hello', '../tools/all.nix']);
// The call edge to callPackage itself is still recorded.
expect(calls).toContain('pkgs.callPackage');
});
it('should mark returned top-level Nix attrset members exported and keep let or nested attrs private', () => {
const code = `
{ lib, stdenv }:
let
localValue = 10;
in
{
exported = localValue;
package = { name }: stdenv.mkDerivation { inherit name; };
nested = {
privateNested = true;
};
inherit (lib) licenses;
}
`;
const result = extractFromSource('default.nix', code);
const node = (name: string) => result.nodes.find((n) => n.name === name);
expect(node('localValue')?.isExported).toBe(false);
expect(node('exported')?.isExported).toBe(true);
expect(node('package')?.kind).toBe('function');
expect(node('package')?.isExported).toBe(true);
expect(node('privateNested')?.isExported).toBe(false);
expect(node('licenses')?.isExported).toBe(true);
});
});
+269
View File
@@ -0,0 +1,269 @@
/**
* Nix module-system option wiring (nix-option-path synthesizer).
*
* An option is DECLARED in one module (`options.launchd.user.agents =
* mkOption { ... }`) and SET in others (`launchd.user.agents.yabai = { ... }`)
* — the module-system evaluator unifies them by option path, so there is no
* static edge to follow. The synthesizer links each config write to the
* declaration whose path is the longest plain-segment prefix of the write
* path, and these tests pin its precision gates: ambiguous declarations bail,
* dynamic path heads never match, 1-segment paths never register (a package's
* `meta = { ... }` must not link to `options.meta`), and submodule-internal
* `options` blocks are quarantined.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('nix-option-path synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nix-option-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
async function synthEdges(d: string): Promise<any[]> {
const cg = await CodeGraph.init(d, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source, s.file_path sf, t.name target, t.file_path tf,
json_extract(e.metadata,'$.optionPath') optionPath
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'nix-option-path'`
)
.all();
cg.destroy();
return rows;
}
it('links a cross-file config write to its flat option declaration', async () => {
fs.mkdirSync(path.join(dir, 'modules'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'modules', 'launchd.nix'),
`{ config, lib, ... }:
{
options.launchd.user.agents = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {});
default = {};
description = "launchd agents";
};
}
`
);
fs.writeFileSync(
path.join(dir, 'modules', 'yabai.nix'),
`{ config, lib, ... }:
{
config = lib.mkIf config.services.yabai.enable {
launchd.user.agents.yabai = {
command = "yabai";
keepAlive = true;
};
};
}
`
);
const edges = await synthEdges(dir);
const hit = edges.find((e) => e.source === 'launchd.user.agents.yabai');
expect(hit).toBeDefined();
expect(hit.target).toBe('options.launchd.user.agents');
expect(hit.tf).toBe('modules/launchd.nix');
expect(hit.optionPath).toBe('launchd.user.agents');
});
it('composes nested declaration spellings and prefers the longest declared prefix', async () => {
fs.writeFileSync(
path.join(dir, 'git-module.nix'),
`{ lib, ... }:
{
options = {
programs.git = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
};
signing.key = lib.mkOption {
type = lib.types.str;
default = "";
};
};
};
}
`
);
fs.writeFileSync(
path.join(dir, 'user-config.nix'),
`{ ... }:
{
programs.git.enable = true;
programs.git.signing.key = "ABCD1234";
}
`
);
const edges = await synthEdges(dir);
const enable = edges.find((e) => e.source === 'programs.git.enable');
const key = edges.find((e) => e.source === 'programs.git.signing.key');
expect(enable).toBeDefined();
// Longest declared prefix wins: the leaf `enable` declaration, not `programs.git`.
expect(enable.optionPath).toBe('programs.git.enable');
expect(enable.target).toBe('enable');
expect(key).toBeDefined();
expect(key.optionPath).toBe('programs.git.signing.key');
expect(key.target).toBe('signing.key');
});
it('matches through a quoted segment only up to the static prefix', async () => {
fs.writeFileSync(
path.join(dir, 'xdg.nix'),
`{ lib, ... }:
{
options.xdg.configFile = lib.mkOption {
type = lib.types.attrsOf (lib.types.anything);
default = {};
};
}
`
);
fs.writeFileSync(
path.join(dir, 'writer.nix'),
`{ ... }:
{
xdg.configFile."git/config".text = "[user]";
}
`
);
const edges = await synthEdges(dir);
const hit = edges.find((e) => e.sf === 'writer.nix');
expect(hit).toBeDefined();
expect(hit.optionPath).toBe('xdg.configFile');
expect(hit.target).toBe('options.xdg.configFile');
});
it('anchors quoted writes to their own quoted declaration, never a sibling', async () => {
// NSGlobalDomain-style enumerated quoted options: each quoted write must
// hit ITS declaration; an undeclared quoted write must not fall back to a
// same-prefix sibling.
fs.writeFileSync(
path.join(dir, 'domain.nix'),
`{ lib, ... }:
{
options = {
system.defaults.NSGlobalDomain."com.apple.keyboard.fnState" = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
};
system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior" = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
};
};
}
`
);
fs.writeFileSync(
path.join(dir, 'writer.nix'),
`{ ... }:
{
system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior" = 1;
system.defaults.NSGlobalDomain."com.apple.undeclared.domain" = 2;
}
`
);
const edges = await synthEdges(dir);
const tap = edges.filter((e) => e.sf === 'writer.nix' && e.source.includes('tapBehavior'));
expect(tap).toHaveLength(1);
expect(tap[0].target).toContain('tapBehavior');
expect(tap[0].optionPath).toBe('system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior"');
// No parent declaration exists, so the undeclared quoted write stays silent.
expect(edges.filter((e) => e.source.includes('undeclared'))).toEqual([]);
});
it('bails on ambiguous declarations and dynamic path heads; never registers 1-segment paths', async () => {
fs.writeFileSync(
path.join(dir, 'dup-a.nix'),
`{ lib, ... }: { options.services.dup = lib.mkOption { default = {}; }; }
`
);
fs.writeFileSync(
path.join(dir, 'dup-b.nix'),
`{ lib, ... }:
{
options.services.dup = lib.mkOption {
default = {};
};
}
`
);
fs.writeFileSync(
path.join(dir, 'meta-decl.nix'),
`{ lib, ... }:
{
options.meta = lib.mkOption {
default = {};
};
}
`
);
fs.writeFileSync(
path.join(dir, 'writers.nix'),
`{ name, ... }:
{
services.dup.enable = true;
services.\${name}.enable = true;
meta.maintainers = [ "someone" ];
}
`
);
const edges = await synthEdges(dir);
// services.dup is declared in two files → ambiguous → no edge at all.
expect(edges.filter((e) => e.source === 'services.dup.enable')).toEqual([]);
// The interpolated head leaves <2 static segments → no edge.
expect(edges.filter((e) => e.sf === 'writers.nix' && e.optionPath?.startsWith('services'))).toEqual([]);
// `options.meta` is a 1-segment path → never registered, `meta.*` writes stay unlinked.
expect(edges.filter((e) => e.source?.startsWith('meta.'))).toEqual([]);
});
it('quarantines submodule-internal options blocks', async () => {
fs.writeFileSync(
path.join(dir, 'agents.nix'),
`{ lib, ... }:
{
options.launchd.agents = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
command.text = lib.mkOption {
type = lib.types.str;
default = "";
};
};
});
};
}
`
);
fs.writeFileSync(
path.join(dir, 'writer.nix'),
`{ ... }:
{
command.text = "not an option write";
launchd.agents.myapp = { };
}
`
);
const edges = await synthEdges(dir);
// The submodule's own `command.text` namespace is not globally addressable.
expect(edges.filter((e) => e.source === 'command.text')).toEqual([]);
// The outer attrsOf declaration still anchors writes into the attr set.
const hit = edges.find((e) => e.source === 'launchd.agents.myapp');
expect(hit).toBeDefined();
expect(hit.optionPath).toBe('launchd.agents');
});
});
+195
View File
@@ -4399,4 +4399,199 @@ procedure Helper; var t: TTgt; begin t.Hit; end;
expect(callerNamesOf('TTgt::Hit')).toEqual(['DoStuff', 'Helper']);
});
});
describe('Nix path import resolution', () => {
function fileNode(filePath: string) {
return cg.getNodesByKind('file').find((n) => n.filePath === filePath);
}
function importedFilePaths(fromFile: string): string[] {
const source = fileNode(fromFile);
expect(source, `${fromFile} file node`).toBeDefined();
return cg
.getOutgoingEdges(source!.id)
.filter((edge) => edge.kind === 'imports')
.map((edge) => cg.getNodesByKind('file').find((n) => n.id === edge.target)?.filePath)
.filter((filePath): filePath is string => Boolean(filePath))
.sort();
}
it('resolves relative Nix imports to indexed file nodes', async () => {
fs.mkdirSync(path.join(tempDir, 'core'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'core', 'ports.nix'), '{ http = 80; https = 443; }');
fs.writeFileSync(
path.join(tempDir, 'data', 'postgresql.nix'),
`let
ports = import ../core/ports.nix;
in
{
port = ports.https;
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
expect(importedFilePaths('data/postgresql.nix')).toEqual(['core/ports.nix']);
});
it('resolves Nix directory imports through default.nix and deduplicates called imports', async () => {
fs.mkdirSync(path.join(tempDir, 'dir'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'dir', 'default.nix'), '{ value = 1; }');
fs.writeFileSync(path.join(tempDir, 'x.nix'), '{ value = 2; }');
fs.writeFileSync(
path.join(tempDir, 'main.nix'),
`let
dir = import ./dir;
x = import ./x.nix {};
in
{
inherit dir x;
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
expect(importedFilePaths('main.nix')).toEqual(['dir/default.nix', 'x.nix']);
});
it('resolves NixOS module imports lists and callPackage paths to file nodes', async () => {
fs.mkdirSync(path.join(tempDir, 'modules'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'common'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'pkgs', 'hello'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'modules', 'users.nix'), '{ users.users.demo.isNormalUser = true; }');
fs.writeFileSync(path.join(tempDir, 'common', 'default.nix'), '{ time.timeZone = "UTC"; }');
fs.writeFileSync(
path.join(tempDir, 'pkgs', 'hello', 'default.nix'),
'{ stdenv }: stdenv.mkDerivation { pname = "hello"; }'
);
fs.writeFileSync(
path.join(tempDir, 'configuration.nix'),
`{ config, pkgs, ... }:
{
imports = [ ./modules/users.nix ./common ];
environment.systemPackages = [ (pkgs.callPackage ./pkgs/hello { }) ];
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
expect(importedFilePaths('configuration.nix')).toEqual([
'common/default.nix',
'modules/users.nix',
'pkgs/hello/default.nix',
]);
});
it('never resolves another language\'s calls into nix bindings', async () => {
// Nix bindings are not linkable symbols from any other language —
// interop is eval/CLI. Without the target-side gate, a Python script's
// bare `resolve(...)` exact-matches a module's `resolve = ...` binding.
fs.writeFileSync(
path.join(tempDir, 'helpers.nix'),
`let
resolve = x: x;
in
{
inherit resolve;
}
`
);
fs.writeFileSync(path.join(tempDir, 'tool.py'), 'def main():\n return resolve("target")\n');
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const nixNodeIds = new Set(
cg.getNodesByKind('variable').filter((n) => n.language === 'nix').map((n) => n.id)
);
const pyFns = cg.getNodesByKind('function').filter((n) => n.language === 'python');
expect(pyFns.length).toBeGreaterThan(0);
const crossEdges = pyFns.flatMap((f) => cg.getOutgoingEdges(f.id)).filter((e) => nixNodeIds.has(e.target));
expect(crossEdges).toEqual([]);
});
it('never cross-links Nix calls by bare name across files (lexical scope only)', async () => {
// Both modules `inherit (lib) mkOption` — the nixpkgs idiom. A call to
// mkOption in one file must NOT resolve to the other file's inherit
// binding: Nix has no ambient cross-file namespace, so any such edge is
// wrong by construction. Same-file bindings still resolve.
fs.writeFileSync(
path.join(tempDir, 'alpha.nix'),
`{ lib, ... }:
let
inherit (lib) mkOption;
mkPort = default: mkOption { inherit default; };
in
{
options.alpha.port = mkPort 8080;
}
`
);
fs.writeFileSync(
path.join(tempDir, 'beta.nix'),
`{ lib, ... }:
let
inherit (lib) mkOption;
in
{
options.beta.enable = mkOption { default = false; };
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const crossFileCalls = cg
.getNodesByKind('file')
.flatMap((f) => cg.getOutgoingEdges(f.id))
.concat(
cg.getNodesByKind('function').flatMap((f) => cg.getOutgoingEdges(f.id)),
cg.getNodesByKind('variable').flatMap((v) => cg.getOutgoingEdges(v.id))
)
.filter((e) => e.kind === 'calls')
.map((e) => {
const src = cg.getNode(e.source);
const tgt = cg.getNode(e.target);
return { from: src?.filePath, to: tgt?.filePath, name: tgt?.name };
});
// No calls edge may cross files by bare-name matching.
expect(crossFileCalls.filter((e) => e.from !== e.to)).toEqual([]);
// The same-file chain still resolves: mkPort's mkOption call hits
// alpha.nix's own inherit binding.
const sameFile = crossFileCalls.filter((e) => e.from === e.to && e.name === 'mkOption');
expect(sameFile.length).toBeGreaterThan(0);
expect(sameFile.every((e) => e.from === 'alpha.nix' || e.from === 'beta.nix')).toBe(true);
});
it('does not resolve Nix angle-bracket, attribute, or variable imports as project file edges', async () => {
fs.writeFileSync(path.join(tempDir, 'nixpkgs.nix'), '{ bogus = true; }');
fs.writeFileSync(path.join(tempDir, 'selectedPath.nix'), '{ bogus = true; }');
fs.writeFileSync(
path.join(tempDir, 'main.nix'),
`let
pkgs = import <nixpkgs> {};
fromSources = import sources.nixpkgs {};
dynamic = import selectedPath;
in
{
inherit pkgs fromSources dynamic;
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
expect(importedFilePaths('main.nix')).toEqual([]);
});
});
});