Преглед изворни кода

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>
Colby Mchenry пре 2 месеци
родитељ
комит
7f325134e0

+ 23 - 0
.claude/skills/agent-eval/corpus.json

@@ -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 - 0
CHANGELOG.md

@@ -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
README.md

@@ -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 - 0
__tests__/extraction.test.ts

@@ -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 - 0
__tests__/nix-option-synthesizer.test.ts

@@ -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 - 0
__tests__/resolution.test.ts

@@ -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 - 0
src/db/queries.ts

@@ -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
src/extraction/grammars.ts

@@ -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 - 0
src/extraction/languages/index.ts

@@ -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 - 0
src/extraction/languages/nix.ts

@@ -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;
+  },
+};

BIN
src/extraction/wasm/tree-sitter-nix.wasm


+ 5 - 0
src/index.ts

@@ -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
src/mcp/tools.ts

@@ -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 - 0
src/resolution/callback-synthesizer.ts

@@ -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 - 0
src/resolution/import-resolver.ts

@@ -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
src/resolution/index.ts

@@ -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 - 0
src/types.ts

@@ -93,6 +93,7 @@ export const LANGUAGES = [
   'objc',
   'r',
   'solidity',
+  'nix',
   'yaml',
   'twig',
   'xml',