1
0
Tyce Herrman 2 сар өмнө
parent
commit
6840c4e51b

+ 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) |
 
 ## Measured cross-file coverage
 

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

+ 82 - 0
__tests__/resolution.test.ts

@@ -4399,4 +4399,86 @@ 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('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([]);
+    });
+  });
 });

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

+ 259 - 0
src/extraction/languages/nix.ts

@@ -0,0 +1,259 @@
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import { getNodeText } from '../tree-sitter-helpers';
+import type { 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;
+}
+
+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),
+        });
+        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';
+      const isCalleeOfParent =
+        node.parent?.type === 'apply_expression' &&
+        (node.parent.childForFieldName('function') === node || node.parent.namedChild(0) === node);
+
+      if (!(isCalleeOfParent && !isDirectImport)) {
+        if (isDirectImport) {
+          const argNode = node.childForFieldName('argument') || node.namedChild(1);
+          const importPath = argNode ? getStaticImportPath(argNode, source) : null;
+
+          if (importPath) {
+            const impNode = ctx.createNode('import', importPath, node, {
+              signature: getNodeText(node, 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: node.startPosition.row + 1,
+                  column: node.startPosition.column,
+                });
+              }
+            }
+          }
+        } 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,
+              });
+            }
+          }
+        }
+      }
+
+      for (const child of node.namedChildren) {
+        ctx.visitNode(child);
+      }
+      return true;
+    }
+
+    return false;
+  },
+};

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


+ 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)) {

+ 7 - 2
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

+ 1 - 0
src/types.ts

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