diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 574cc1a..150b4a6 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.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." + } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a71c5..0eb4d4d 100644 --- a/CHANGELOG.md +++ b/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 `, 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. diff --git a/README.md b/README.md index e19b8a3..81c1223 100644 --- a/README.md +++ b/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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 2b9beca..f4490c2 100644 --- a/__tests__/extraction.test.ts +++ b/__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 {}; + 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); }); }); diff --git a/__tests__/nix-option-synthesizer.test.ts b/__tests__/nix-option-synthesizer.test.ts new file mode 100644 index 0000000..c72f552 --- /dev/null +++ b/__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 { + 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'); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 144947d..4d440e6 100644 --- a/__tests__/resolution.test.ts +++ b/__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 {}; + 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([]); + }); + }); }); diff --git a/src/db/queries.ts b/src/db/queries.ts index 0cdc93a..c7acf74 100644 --- a/src/db/queries.ts +++ b/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) */ diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 2d74b52..2565e02 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -48,6 +48,7 @@ const WASM_GRAMMAR_FILES: Record = { 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 = { // see c-cpp.ts) blanks the CUDA-only tokens. (#387) '.cu': 'cpp', '.cuh': 'cpp', + '.nix': 'nix', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -303,7 +305,12 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -67,4 +68,5 @@ export const EXTRACTORS: Partial> = { solidity: solidityExtractor, terraform: terraformExtractor, arkts: arktsExtractor, + nix: nixExtractor, }; diff --git a/src/extraction/languages/nix.ts b/src/extraction/languages/nix.ts new file mode 100644 index 0000000..5071829 --- /dev/null +++ b/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; + }, +}; diff --git a/src/extraction/wasm/tree-sitter-nix.wasm b/src/extraction/wasm/tree-sitter-nix.wasm new file mode 100755 index 0000000..4bc05ad Binary files /dev/null and b/src/extraction/wasm/tree-sitter-nix.wasm differ diff --git a/src/index.ts b/src/index.ts index 518f798..c464e01 100644 --- a/src/index.ts +++ b/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 */ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 56f4c38..55ef1e5 100644 --- a/src/mcp/tools.ts +++ b/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.` and + // config writes carry longer/quoted tails (`."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(); + 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 diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 54e5494..92d28b8 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/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 { + 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(); + 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(); + 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(); @@ -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; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index a597279..bbb1303 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -40,8 +40,18 @@ const EXTENSION_RESOLUTION: Record = { 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)) { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 094a2b2..ac82c5e 100644 --- a/src/resolution/index.ts +++ b/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); } diff --git a/src/types.ts b/src/types.ts index 860b392..d160d04 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,7 @@ export const LANGUAGES = [ 'objc', 'r', 'solidity', + 'nix', 'yaml', 'twig', 'xml',