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
+23
View File
@@ -584,5 +584,28 @@
"files": "~9500",
"question": "In the OrangeShopping sample app, how does the product detail page's bottom bar (add to cart / buy) lead to the order placement flow? Trace from the bottom navigation component to where the order is created."
}
],
"Nix": [
{
"name": "agenix",
"repo": "https://github.com/ryantm/agenix",
"size": "Small",
"files": "~13",
"question": "When a NixOS system activates, how does a secret declared under age.secrets get decrypted and installed at its runtime path? Trace the flow from the age.secrets option definition to the activation machinery that performs the decryption."
},
{
"name": "nix-darwin",
"repo": "https://github.com/nix-darwin/nix-darwin",
"size": "Medium",
"files": "~207",
"question": "How does setting services.yabai.enable = true become a running launchd service? Trace the flow from the yabai module's options to the launchd daemon definition that generates the plist."
},
{
"name": "home-manager",
"repo": "https://github.com/nix-community/home-manager",
"size": "Large",
"files": "~3390",
"question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place."
}
]
}
+2
View File
@@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
- CodeGraph now indexes **Nix** (`.nix`) — flakes, NixOS and home-manager modules, overlays, and package sets join the graph: `let` and attrset bindings, functions (simple, destructured `{ pkgs, ... }`, and curried), and `inherit` bindings all become searchable symbols, with call edges between bindings. File-level wiring follows the ways Nix actually connects files: `import ./relative/path.nix` (with `import ./dir` reaching the directory's `default.nix`), NixOS module `imports = [ ./hardware.nix ../common ]` lists, flake-style `modules = [ ./configuration.nix ]` lists, and the nixpkgs `callPackage ./pkgs/foo { }` idiom — so "what does this configuration actually pull in" and "what uses this module" are answerable on real setups. Dynamic references (`import <nixpkgs>`, variable paths, flake-input module references) are deliberately left unlinked rather than guessed. Thanks @TyceHerrman. (#324, #332, #648)
- The NixOS module system's option wiring is bridged, so flow questions cross the module boundary instead of going dark at it: a config write like `launchd.user.agents.myapp = { ... }` or `home.file.".gitconfig" = { ... }` links to the module that declares that option (`options.launchd.user.agents = mkOption { ... }` — flat and nested declaration spellings both count, quoted keys like `system.defaults.NSGlobalDomain."com.apple.dock"` match their exact declaration), which makes "how does enabling this service produce the launchd daemon / generated config file" traceable end-to-end and "what sets this option" answerable across modules, tests included. Precision is deliberately conservative: interpolated `${...}` paths, options declared in more than one module, and submodule-internal option namespaces stay unlinked rather than guessed, and every bridged hop is labeled as heuristic module-system wiring rather than shown as a plain reference.
- CodeGraph now indexes **ArkTS** (`.ets`) — the language of HarmonyOS / OpenHarmony apps. Everything TypeScript gets extracted (classes, interfaces, enums, type aliases, imports/exports, call edges), plus ArkTS's own constructs: `@Component` / `@ComponentV2` structs with their decorators (`@Entry`, `@State`, `@Prop`, `@Link`, `@Local`, `@Param`, …) captured and searchable, `build()` view trees linked parent→child so "which pages render this component" is answerable, chained attributes connected to the `@Extend`/`@Styles` functions they invoke, `@Builder` methods and functions wired into the call graph, and `.onClick(this.handler)`-style event bindings linked to their handler methods. Modular HarmonyOS projects resolve across module boundaries too: a bare `import { CartRepository } from "data"` follows the `oh-package.json5` `file:` dependency to the right module — honoring each module's declared `main` entry, from `.ets` and `.ts` consumers alike — while ambiguous names in multi-app monorepos deliberately stay unlinked rather than guessed, and mixed `.ets`/`.ts` codebases cross-link freely. Validated on real HarmonyOS apps including the official OpenHarmony samples monorepo. (#396, #512, #648, #890)
- ArkUI's dynamic hops are bridged so flow questions cross them instead of going dark, each labeled as dynamic dispatch rather than shown as a plain call: methods that assign a reactive property (`@State`, `@Local`, …) link to the component's `build()` (the re-render hop — assignment-gated, so a method that merely reads state gets no edge); `emitter.emit(eventId)` links to the matching `emitter.on/once` subscriber when both sides share a statically-recoverable event key (numeric ids pair within one file only, named constants within one module, so unrelated samples in a monorepo never cross-link); and `router.pushUrl({ url: 'pages/Detail' })` links to the target page's `@Entry` struct, with ambiguous urls left unlinked rather than guessed.
- Interrupted or incomplete indexing is now visible instead of silent: a run killed mid-index (crash, out-of-memory, watchdog) leaves a marker that `codegraph status` reports as a truncated index, a completed run that dropped files reports itself as partial — both in the human output and in `status --json` — and `codegraph index` prints a warning with the exact counts when its result doesn't add up to what the scan discovered.
+2 -1
View File
@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
@@ -723,6 +723,7 @@ is written):
| Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |
## Measured cross-file coverage
+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([]);
});
});
});
+15
View File
@@ -211,6 +211,7 @@ export class QueryBuilder {
deleteUnresolvedByNode?: SqliteStatement;
getUnresolvedByName?: SqliteStatement;
getNodesByName?: SqliteStatement;
getNodesByNamePrefix?: SqliteStatement;
getNodesByQualifiedNameExact?: SqliteStatement;
getNodesByLowerName?: SqliteStatement;
getUnresolvedCount?: SqliteStatement;
@@ -890,6 +891,20 @@ export class QueryBuilder {
return rows.map(rowToNode);
}
/**
* Nodes whose name starts with `prefix`, by index range scan (a LIKE would
* skip idx_nodes_name under SQLite's default case-insensitive LIKE).
*/
getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
if (!this.stmts.getNodesByNamePrefix) {
this.stmts.getNodesByNamePrefix = this.db.prepare(
'SELECT * FROM nodes WHERE name >= ? AND name < ? ORDER BY name LIMIT ?'
);
}
const rows = this.stmts.getNodesByNamePrefix.all(prefix, prefix + '￿', limit) as NodeRow[];
return rows.map(rowToNode);
}
/**
* Get nodes by exact qualified name match (uses idx_nodes_qualified_name index)
*/
+9 -1
View File
@@ -48,6 +48,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
solidity: 'tree-sitter-solidity.wasm',
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
nix: 'tree-sitter-nix.wasm',
};
/**
@@ -138,6 +139,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
// see c-cpp.ts) blanks the CUDA-only tokens. (#387)
'.cu': 'cpp',
'.cuh': 'cpp',
'.nix': 'nix',
// XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
// shape and emits SQL-statement nodes (other XML returns empty).
'.xml': 'xml',
@@ -303,7 +305,12 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// tarball's artifact. It extends the tree-sitter-javascript grammar the
// same way tree-sitter-typescript does, adding `struct_declaration` and
// the `arkui_component_expression` build() DSL.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts')
// Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from
// nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli
// 0.25.10 (`generate` + `build --wasm`, ABI 15 — upstream's checked-in
// parser.c is still ABI 13; all 54 upstream corpus tests pass on the
// regenerated parser).
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts' || lang === 'nix')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
@@ -518,6 +525,7 @@ export function getLanguageDisplayName(language: Language): string {
luau: 'Luau',
objc: 'Objective-C',
solidity: 'Solidity',
nix: 'Nix',
yaml: 'YAML',
twig: 'Twig',
xml: 'XML',
+2
View File
@@ -35,6 +35,7 @@ import { erlangExtractor } from './erlang';
import { solidityExtractor } from './solidity';
import { terraformExtractor } from './terraform';
import { arktsExtractor } from './arkts';
import { nixExtractor } from './nix';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -67,4 +68,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
solidity: solidityExtractor,
terraform: terraformExtractor,
arkts: arktsExtractor,
nix: nixExtractor,
};
+324
View File
@@ -0,0 +1,324 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
function unwrapVariableExpression(node: SyntaxNode): SyntaxNode {
if (node.type !== 'variable_expression') return node;
return node.namedChild(0) ?? node;
}
function getCalleeName(node: SyntaxNode, source: string): string | null {
let current = node;
while (current.type === 'apply_expression') {
const funcNode = current.childForFieldName('function') || current.namedChild(0);
if (!funcNode) break;
current = funcNode;
}
current = unwrapVariableExpression(current);
if (current.type === 'identifier' || current.type === 'select_expression') {
return getNodeText(current, source).trim();
}
return null;
}
function getDirectCalleeName(node: SyntaxNode, source: string): string | null {
let funcNode = node.childForFieldName('function') || node.namedChild(0);
if (!funcNode) return null;
funcNode = unwrapVariableExpression(funcNode);
return getNodeText(funcNode, source).trim();
}
function isStaticProjectPath(value: string): boolean {
return (
(value.startsWith('./') || value.startsWith('../')) &&
!/[\s{}()[\];"'<>$]/.test(value)
);
}
function getStaticImportPath(argNode: SyntaxNode, source: string): string | null {
let current = argNode;
while (current.type === 'parenthesized_expression') {
const inner = current.namedChild(0);
if (!inner) break;
current = inner;
}
let text = getNodeText(current, source).trim();
if (
((text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))) &&
text.length >= 2
) {
text = text.slice(1, -1);
}
return isStaticProjectPath(text) ? text : null;
}
function isReturnedAttrsetMember(node: SyntaxNode): boolean {
let current: SyntaxNode | null = node;
let seenReturnedAttrset = false;
while (current) {
const parent: SyntaxNode | null = current.parent;
if (!parent) break;
if (parent.type === 'let_expression') {
const bodyNode = parent.childForFieldName('body') || parent.childForFieldName('expression');
if (!bodyNode || !bodyNode.equals(current)) return false;
}
if (parent.type === 'binding' && !current.equals(node)) return false;
if (parent.type === 'formal_parameters' || parent.type === 'formals') return false;
if (
parent.type === 'attrset' ||
parent.type === 'rec_attrset' ||
parent.type === 'attrset_expression' ||
parent.type === 'rec_attrset_expression'
) {
seenReturnedAttrset = true;
}
current = parent;
}
return seenReturnedAttrset;
}
function getCurriedParamsAndBody(node: SyntaxNode, source: string): { params: string[]; bodyNode: SyntaxNode | null } {
const params: string[] = [];
let current = node;
while (current.type === 'function_expression' && current.namedChildCount > 0) {
const bodyNode = current.namedChild(current.namedChildCount - 1);
if (!bodyNode) break;
const paramPart = source.substring(current.startIndex, bodyNode.startIndex).trim();
const paramText = paramPart.endsWith(':') ? paramPart.slice(0, -1).trim() : paramPart;
if (paramText) params.push(paramText);
if (bodyNode.type === 'function_expression') {
current = bodyNode;
} else {
return { params, bodyNode };
}
}
return {
params,
bodyNode: current.namedChildCount > 0 ? current.namedChild(current.namedChildCount - 1) : null,
};
}
function formatFunctionSignature(params: string[]): string {
if (params.length === 0) return '()';
if (params.length > 1) return params.join(' : ');
const [param] = params;
if (!param) return '()';
return param.startsWith('(') || param.includes('{') || param.includes('@') ? param : `(${param})`;
}
function inheritedAttrs(node: SyntaxNode): SyntaxNode | null {
return node.namedChildren.find((child) => child.type === 'inherited_attrs') ?? null;
}
/**
* `callPackage ./pkg.nix { }` and `pkgs.callPackage ../tools/foo { }` the
* nixpkgs auto-wiring idiom reference a file the same way `import` does.
*/
function isCallPackageName(name: string): boolean {
return (
name === 'callPackage' ||
name === 'callPackages' ||
name.endsWith('.callPackage') ||
name.endsWith('.callPackages')
);
}
/** Innermost argument of a curried apply chain: `f a b` → `a`. */
function getFirstApplyArgument(node: SyntaxNode): SyntaxNode | null {
let inner = node;
for (;;) {
const fn = inner.childForFieldName('function') || inner.namedChild(0);
if (fn && fn.type === 'apply_expression') {
inner = fn;
continue;
}
break;
}
return inner.childForFieldName('argument') || inner.namedChild(1);
}
/** Import node + unresolved `imports` ref for a static project path. */
function emitFileImport(ctx: ExtractorContext, importPath: string, anchorNode: SyntaxNode, source: string): void {
const impNode = ctx.createNode('import', importPath, anchorNode, {
signature: getNodeText(anchorNode, source).trim().slice(0, 100),
});
if (impNode && ctx.nodeStack.length > 0) {
const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1];
if (fromNodeId) {
ctx.addUnresolvedReference({
fromNodeId,
referenceName: importPath,
referenceKind: 'imports',
line: anchorNode.startPosition.row + 1,
column: anchorNode.startPosition.column,
});
}
}
}
export const nixExtractor: LanguageExtractor = {
functionTypes: [],
classTypes: [],
methodTypes: [],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: [],
callTypes: [],
variableTypes: [],
nameField: '',
bodyField: '',
paramsField: '',
visitNode: (node, ctx) => {
const { source } = ctx;
if (node.type === 'binding') {
const attrpath = node.childForFieldName('attrpath') || node.namedChild(0);
if (!attrpath) return false;
const name = getNodeText(attrpath, source).trim();
if (!name) return false;
const valueNode = node.childForFieldName('expression') || node.childForFieldName('value') || node.namedChild(1);
if (!valueNode) return false;
if (valueNode.type === 'function_expression') {
const { params, bodyNode } = getCurriedParamsAndBody(valueNode, source);
const funcNode = ctx.createNode('function', name, node, {
signature: formatFunctionSignature(params),
isExported: isReturnedAttrsetMember(node),
});
if (funcNode) {
ctx.pushScope(funcNode.id);
if (bodyNode) ctx.visitNode(bodyNode);
ctx.popScope();
}
} else {
const initValue = getNodeText(valueNode, source).slice(0, 100);
ctx.createNode('variable', name, node, {
signature: initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined,
isExported: isReturnedAttrsetMember(node),
});
// NixOS/home-manager module lists: `imports = [ ./hardware.nix ../common ]`
// (and the flake-era `modules = [ ./configuration.nix ]`) reference files
// without an `import` call. Only literal `path_expression` entries count —
// variables and interpolations stay dynamic (silent beats wrong).
const finalSegment = name.split('.').pop();
if ((finalSegment === 'imports' || finalSegment === 'modules') && valueNode.type === 'list_expression') {
for (const child of valueNode.namedChildren) {
if (child.type === 'path_expression') {
const entryPath = getNodeText(child, source).trim();
if (isStaticProjectPath(entryPath)) {
emitFileImport(ctx, entryPath, child, source);
}
}
}
}
ctx.visitNode(valueNode);
}
return true;
}
if (node.type === 'function_expression') {
const bodyNode = node.namedChild(node.namedChildCount - 1);
if (bodyNode) ctx.visitNode(bodyNode);
return true;
}
if (node.type === 'inherit' || node.type === 'inherit_from') {
const attrs = inheritedAttrs(node);
if (attrs) {
for (const child of attrs.namedChildren) {
const name = getNodeText(child, source).trim();
if (name) {
ctx.createNode('variable', name, child, {
isExported: isReturnedAttrsetMember(child),
});
}
}
}
for (const child of node.namedChildren) {
if (child.type !== 'inherited_attrs') ctx.visitNode(child);
}
return true;
}
if (node.type === 'apply_expression') {
const directCallee = getDirectCalleeName(node, source);
const isDirectImport = directCallee === 'import' || directCallee === 'builtins.import';
// Wrapper objects are re-created per access, so compare with .equals(),
// never === — otherwise every level of a curried chain (`f a b`)
// re-emits the same refs.
const parentFn =
node.parent?.type === 'apply_expression'
? (node.parent.childForFieldName('function') ?? node.parent.namedChild(0))
: null;
const isCalleeOfParent = parentFn ? parentFn.equals(node) : false;
if (!(isCalleeOfParent && !isDirectImport)) {
if (isDirectImport) {
const argNode = node.childForFieldName('argument') || node.namedChild(1);
const importPath = argNode ? getStaticImportPath(argNode, source) : null;
if (importPath) {
emitFileImport(ctx, importPath, node, source);
}
} else {
const calleeName = getCalleeName(node, source);
if (calleeName && calleeName !== 'import' && calleeName !== 'builtins.import' && ctx.nodeStack.length > 0) {
const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1];
if (fromNodeId) {
ctx.addUnresolvedReference({
fromNodeId,
referenceName: calleeName,
referenceKind: 'calls',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
}
// `callPackage ./pkg.nix { }` loads the file like `import` does; the
// first argument of the apply chain is the package file. Only a
// literal static path counts (`callPackage pkgPath { }` stays dynamic).
if (calleeName && isCallPackageName(calleeName)) {
const firstArg = getFirstApplyArgument(node);
const importPath = firstArg ? getStaticImportPath(firstArg, source) : null;
if (importPath) {
emitFileImport(ctx, importPath, node, source);
}
}
}
}
for (const child of node.namedChildren) {
ctx.visitNode(child);
}
return true;
}
return false;
},
};
Binary file not shown.
+5
View File
@@ -953,6 +953,11 @@ export class CodeGraph {
return this.queries.getNodesByName(name);
}
/** Nodes whose name starts with `prefix` (index range scan, capped). */
getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
return this.queries.getNodesByNamePrefix(prefix, limit);
}
/**
* Search nodes by text
*/
+29 -2
View File
@@ -1944,11 +1944,18 @@ export class ToolHandler {
}
// Same token, non-callable synth endpoints (capped, precision-gated on an
// actual heuristic edge so plain config constants never qualify).
// Per-token sub-cap so one token's many endpoints (10 nix option writes
// of `programs.git.enable` across test configs) can't fill the pool
// before later tokens (`home.file`) get a slot.
if (dynNamed.size < 12) {
let tokenDyn = 0;
for (const n of hits) {
if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
if (hasHeuristicEdge(n.id)) dynNamed.set(n.id, n);
if (dynNamed.size >= 12) break;
if (hasHeuristicEdge(n.id)) {
dynNamed.set(n.id, n);
tokenDyn++;
}
if (dynNamed.size >= 12 || tokenDyn >= 4) break;
}
}
if (named.size > 40) break;
@@ -4399,6 +4406,26 @@ export class ToolHandler {
* results across all matching symbols (e.g., multiple classes with an `execute` method).
*/
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
// Nix option paths: the declaration is stored as `options.<path>` and
// config writes carry longer/quoted tails (`<path>."git/config".text`),
// so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
// no exact-name node and would degrade to bare-tail FTS soup — burying
// the declaration hub the nix-option-path edges hang off. Resolve the
// convention directly: declaration first, then the exact write, then a
// capped prefix scan of write sites. Three index hits; non-nix graphs
// fall straight through.
if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
const optionHits = [
...cg.getNodesByName(`options.${symbol}`),
...cg.getNodesByName(symbol),
...cg.getNodesByNamePrefix(`${symbol}.`, 12),
].filter((n) => n.language === 'nix');
if (optionHits.length > 0) {
const seen = new Set<string>();
const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
return { nodes, note: '' };
}
}
let results = cg.searchNodes(symbol, { limit: 50 });
// Mirror the fallback in `findSymbol` for qualified queries — FTS
+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);
}
+1
View File
@@ -93,6 +93,7 @@ export const LANGUAGES = [
'objc',
'r',
'solidity',
'nix',
'yaml',
'twig',
'xml',