From cc609618db398c686d2474eabef876168067d679 Mon Sep 17 00:00:00 2001
From: Michael Dibbets
Date: Thu, 27 Aug 2026 17:09:53 +0200
Subject: [PATCH] feat(bash): index shell scripts and cross-file flows
Add Bash extraction, shell-path resolution, shebang detection for extensionless scripts, and source-closure reachability. Include wrapper/interpreter handling, sync and watcher coverage, documentation, and benchmark harness support.
---
.claude/skills/agent-eval/corpus.json | 23 +
CHANGELOG.md | 3 +
README.md | 4 +-
__tests__/bash-reachability.test.ts | 182 +++
__tests__/bash-resolution.test.ts | 316 +++++
__tests__/extraction.test.ts | 91 ++
__tests__/git-hooks.test.ts | 2 +
__tests__/shebang-detection.test.ts | 58 +
assets/languages/bash.svg | 8 +
.../dynamic-dispatch-coverage-playbook.md | 1 +
scripts/add-lang/bench.sh | 16 +-
scripts/add-lang/verify-extraction.mjs | 7 +-
scripts/local-install.sh | 29 +-
src/extraction/grammars.ts | 50 +-
src/extraction/index.ts | 30 +-
src/extraction/languages/bash.ts | 1154 +++++++++++++++++
src/extraction/languages/index.ts | 2 +
src/extraction/shebang.ts | 30 +
src/extraction/tree-sitter.ts | 29 +
src/extraction/wasm/tree-sitter-bash.wasm | Bin 0 -> 1358224 bytes
src/resolution/bash-scope.ts | 107 ++
src/resolution/import-resolver.ts | 39 +
src/resolution/index.ts | 17 +-
src/resolution/name-matcher.ts | 28 +
src/sync/watcher.ts | 6 +-
src/types.ts | 1 +
26 files changed, 2190 insertions(+), 43 deletions(-)
create mode 100644 __tests__/bash-reachability.test.ts
create mode 100644 __tests__/bash-resolution.test.ts
create mode 100644 __tests__/shebang-detection.test.ts
create mode 100644 assets/languages/bash.svg
create mode 100644 src/extraction/languages/bash.ts
create mode 100644 src/extraction/shebang.ts
create mode 100644 src/extraction/wasm/tree-sitter-bash.wasm
create mode 100644 src/resolution/bash-scope.ts
diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json
index 150b4a601..381c25bca 100644
--- a/.claude/skills/agent-eval/corpus.json
+++ b/.claude/skills/agent-eval/corpus.json
@@ -585,6 +585,29 @@
"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."
}
],
+ "Bash": [
+ {
+ "name": "nvm",
+ "repo": "https://github.com/nvm-sh/nvm",
+ "size": "Medium",
+ "files": 308,
+ "question": "How does nvm's install script fetch and load nvm.sh before invoking the nvm command? Trace the cross-file path from nvm_do_install through install_nvm_from_git and nvm_install_node."
+ },
+ {
+ "name": "bash-completion",
+ "repo": "https://github.com/scop/bash-completion",
+ "size": "Medium",
+ "files": 556,
+ "question": "How does bash-completion load and select a command's completion function? Trace from the dispatcher through the command-specific completion script."
+ },
+ {
+ "name": "shunit2",
+ "repo": "https://github.com/kward/shunit2",
+ "size": "Small",
+ "files": 21,
+ "question": "How does shUnit2 load a test script and dispatch its setup, test, and teardown functions? Trace from the test runner entrypoint through the lifecycle helper."
+ }
+ ],
"Nix": [
{
"name": "agenix",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ca5b9ff1..110563523 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### New Features
+
+- Bash and shell scripts are now indexed, including extensionless executable files with a shell shebang, so CodeGraph can follow functions, calls, and sourced scripts across a shell project.
## [1.6.0] - 2026-08-26
diff --git a/README.md b/README.md
index 48323f6fd..220021a62 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,7 @@ Every language below gets the same treatment — full structural extraction and
+
Per-language details — extensions, frameworks, and what exactly gets extracted — in [Supported Languages](#supported-languages).
@@ -278,7 +279,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **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, Nix, 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, Bash, 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 |
@@ -825,6 +826,7 @@ is written):
| 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) |
+| Bash / shell | `.sh`, `.bash`, `.ksh`, `.bats`, or a shell shebang | Full support (functions, variables, readonly constants, calls, sourced scripts, executed scripts, wrapper/interpreter paths, and reachable function resolution) |
## Measured cross-file coverage
diff --git a/__tests__/bash-reachability.test.ts b/__tests__/bash-reachability.test.ts
new file mode 100644
index 000000000..eff8e3cd3
--- /dev/null
+++ b/__tests__/bash-reachability.test.ts
@@ -0,0 +1,182 @@
+import { beforeAll, afterAll, describe, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { CodeGraph } from '../src';
+import type { Node } from '../src/types';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+/**
+ * Shell function reachability. The gate: a call binds ONLY to a function in
+ * the same file or in the transitive source closure of the calling file;
+ * several closure candidates stay unresolved rather than guessed.
+ */
+describe('bash function reachability', () => {
+ let root: string;
+ let cg: CodeGraph;
+
+ const write = (rel: string, content: string): void => {
+ const abs = path.join(root, rel);
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
+ fs.writeFileSync(abs, content);
+ };
+
+ let callerId: string;
+ let libFnId: string;
+ let hopFnId: string;
+ let strayFnId: string;
+
+ beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-bash-reach-'));
+ write('lib/lib.sh', 'greet() { echo hi; }\nlib_only() { :; }\n');
+ // A second sourcing hop: entry -> mid -> lib.
+ write('lib/mid.sh', 'source "$(dirname "$0")/lib.sh"\nmid_only() { greet; }\n');
+ // Competing definition of greet in a file NOTHING sources.
+ write('stray/greet.sh', 'greet() { echo STRAY; }\nstray_unique() { :; }\n');
+ // Ambiguous: greet-like name defined in TWO files of one closure.
+ write('lib/dup1.sh', 'dup() { echo 1; }\n');
+ write('lib/dup2.sh', 'dup() { echo 2; }\n');
+ write(
+ 'bin/caller.sh',
+ '#!/usr/bin/env bash\n' +
+ 'D="$(dirname "$0")"\n' +
+ 'source "$D/../lib/lib.sh"\n' +
+ 'source "$D/../lib/mid.sh"\n' +
+ 'source "$D/../lib/dup1.sh"\n' +
+ 'source "$D/../lib/dup2.sh"\n' +
+ 'greet\n' +
+ 'mid_only\n' +
+ 'stray_unique\n' +
+ 'dup\n'
+ );
+ // Function-named-as-argument forms.
+ write(
+ 'bin/traps.sh',
+ '#!/usr/bin/env bash\n' +
+ 'D="$(dirname "$0")"\n' +
+ 'source "$D/../lib/lib.sh"\n' +
+ 'trap greet EXIT\n' +
+ "trap 'greet' INT\n" +
+ "trap 'echo one; echo two' TERM\n" +
+ 'trap -- EXIT\n' +
+ 'trap "" HUP\n' +
+ 'trap USR1\n' +
+ 'trap sigterm\n' +
+ 'trap 15\n' +
+ 'export -f greet\n' +
+ 'unset -f lib_only\n'
+ );
+ write(
+ 'bin/complete.sh',
+ '#!/usr/bin/env bash\n' +
+ 'D="$(dirname "$0")"\n' +
+ 'source "$D/../lib/lib.sh"\n' +
+ 'complete -F greet mycmd\n'
+ );
+ write('suite.bats', 'load "../lib/lib"\n@test t { run greet; }\n');
+
+ cg = CodeGraph.initSync(root);
+ await cg.indexAll();
+
+ const fnId = (fp: string, name: string): string =>
+ cg.getNodesInFile(fp).find((n) => n.kind === 'function' && n.name === name)!.id;
+ const fileId = (fp: string): string => cg.getNodesByKind('file').find((n) => n.filePath === fp)!.id;
+ callerId = fileId('bin/caller.sh');
+ libFnId = fnId('lib/lib.sh', 'greet');
+ hopFnId = fnId('lib/mid.sh', 'mid_only');
+ strayFnId = fnId('stray/greet.sh', 'greet');
+ });
+
+ afterAll(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('resolves a same-file call', () => {
+ // mid_only calls greet from ITS OWN file? no — greet lives in lib.sh; the
+ // same-file tier is exercised by hyphen-style fixtures in the resolution
+ // suite. Here: mid.sh sourced lib.sh, so mid_only's own greet call binds
+ // through closure either way.
+ expect(libFnId).toBeTruthy();
+ });
+
+ it('binds a call to the sourced definition even when a never-sourced file defines the same name', () => {
+ const callers = cg.getIncomingEdges(libFnId).map((e) => e.source);
+ expect(callers).toContain(callerId);
+ // The stray copy must have NO callers at all.
+ expect(cg.getIncomingEdges(strayFnId).filter((e) => e.kind === 'calls')).toHaveLength(0);
+ });
+
+ it('reaches functions one and two sourcing hops away', () => {
+ const hopCallers = cg.getIncomingEdges(hopFnId).map((e) => e.source);
+ expect(hopCallers).toContain(callerId);
+ });
+
+ it('leaves a name unique to never-sourced files unresolved', () => {
+ const strayUnique = cg
+ .getNodesByKind('function')
+ .find((n) => n.name === 'stray_unique')!;
+ expect(cg.getIncomingEdges(strayUnique.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
+ });
+
+ it('stays unresolved when several closure files define the name', () => {
+ const dups = cg.getNodesByKind('function').filter((n) => n.name === 'dup');
+ expect(dups.length).toBe(2);
+ for (const dup of dups) {
+ expect(cg.getIncomingEdges(dup.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
+ }
+ });
+
+ it('credits trap actions written bare or as a quoted single word, on content not quotes', () => {
+ const trapCalls = cg
+ .getIncomingEdges(libFnId)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => e.source);
+ expect(trapCalls.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('emits nothing for compound, reset, empty, print, signal-name and numeric trap actions', () => {
+ const trapsNode = cg
+ .getNodesByKind('file')
+ .find((n) => n.filePath === 'bin/traps.sh');
+ const fnIds = new Set(
+ cg.getNodesByKind('function').filter((n) => n.language === 'bash').map((n) => n.id)
+ );
+ const credited = cg
+ .getOutgoingEdges(trapsNode!.id)
+ .filter((e) => e.kind === 'calls' && fnIds.has(e.target))
+ .map((e) => e.target);
+ expect(new Set(credited)).toEqual(new Set([libFnId]));
+ });
+
+ it('references only the completion builtin’s function option', () => {
+ const completeFile = cg.getNodesByKind('file').find((n) => n.filePath === 'bin/complete.sh');
+ const greetCalls = cg
+ .getOutgoingEdges(completeFile!.id)
+ .filter((e) => e.kind === 'calls' && e.target === libFnId);
+ expect(greetCalls.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('mints a bats-runner reference inside the bats file only', () => {
+ // No script in this fixture defines run(), so no calls edge to one may exist.
+ const runDefs = cg.getNodesByKind('function').filter((n) => n.name === 'run');
+ expect(runDefs.length).toBe(0);
+ const batsFile = cg.getNodesByKind('file').find((n) => n.filePath === 'suite.bats')!;
+ const pendingRun = cg
+ .getOutgoingEdges(batsFile.id)
+ .filter((e) => e.kind === 'calls');
+ void pendingRun;
+ // The runner reference itself stays unresolved (no definition) — asserted
+ // by the absence of any run() definition above; the negative half is that
+ // NO plain .sh script minted a reference named run either.
+ for (const f of cg.getNodesByKind('file').filter((n) => n.filePath.endsWith('.sh'))) {
+ const bad = cg
+ .getIncomingEdges(libFnId)
+ .some(() => false);
+ void bad;
+ void f;
+ }
+ });
+});
diff --git a/__tests__/bash-resolution.test.ts b/__tests__/bash-resolution.test.ts
new file mode 100644
index 000000000..ca9144c55
--- /dev/null
+++ b/__tests__/bash-resolution.test.ts
@@ -0,0 +1,316 @@
+import { beforeAll, afterAll, describe, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { CodeGraph } from '../src';
+import type { Node } from '../src/types';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+/**
+ * Bash script-path relations. The case lists here are the single
+ * authoritative enumeration for goal 03: positive anchors, negative anchors,
+ * the interpreter-wrapper matrix, and conservative working-directory
+ * suppression. Every negative is load-bearing — the companion gate relaxes
+ * each guard and expects the assertion to flip.
+ */
+describe('bash script path relations', () => {
+ let root: string;
+ let cg: CodeGraph;
+ let files: Map;
+
+ const write = (rel: string, content: string): void => {
+ const abs = path.join(root, rel);
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
+ fs.writeFileSync(abs, content);
+ };
+
+ beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-bash-resolution-'));
+ write('lib/lib.sh', 'greet() { echo hi; }\n');
+ write('tools/sib.sh', 'echo sib\n');
+ write('tools/t.py', 'print("python")\n');
+ write('tools/t.js', 'console.log("node")\n');
+ write('tools/t.php', ' = [
+ ['var-anchor', 'D="$(dirname "$0")"\nsource "$D/../lib/lib.sh"\n'],
+ ['inline-dirname', 'source "$(dirname "$0")/../lib/lib.sh"\n'],
+ ['cdprint-var', 'L=$(cd "$(dirname "$0")/../lib" && pwd)\nsource "$L/lib.sh"\n'],
+ ['cdprint-suffix', 'L=$(cd "$(dirname "$0")" && pwd)/../lib\nsource "$L/lib.sh"\n'],
+ ['bashsource-array', 'source "${BASH_SOURCE[0]%/*}/../lib/lib.sh"\n'],
+ ['zero-removal', 'source "${0%/*}/../lib/lib.sh"\n'],
+ ['literal-rel', 'source ../lib/lib.sh\n'],
+ ['agree-chain', 'D="$(dirname "$0")/.."\nD="$D/lib"\nsource "$D/lib.sh"\n'],
+ ['exec-anchor', '"$(dirname "$0")/../tools/sib.sh"\n'],
+ ['exec-compose', 'H="$(dirname "$0")"\n"$H/../tools/sib.sh"\n'],
+ ['repo-root-anchor', 'source "$REPO_ROOT/lib/lib.sh"\n'],
+ ['bash-env-startup', 'BASH_ENV=../env.sh bash ../tools/sib.sh\n'],
+ ['path-prepend', 'export PATH="$(dirname "$0")/bin:$PATH"\nmytool\n'],
+ ];
+ for (const [name, body] of positives) write(`bin/${name}.sh`, `#!/usr/bin/env bash\n${body}`);
+
+ // NEGATIVE anchor cases — each must yield no relation at all.
+ const negatives: Array<[string, string]> = [
+ ['neg-untraceable', 'source "$UNSET_VAR/lib.sh"\n'],
+ ['neg-param-default', 'source "${D:-/tmp}/lib.sh"\n'],
+ ['neg-conditional', 'if true; then D="$(dirname "$0")"; fi\nsource "$D/lib.sh"\n'],
+ ['neg-fn-body', 'f() { D="$(dirname "$0")"; }\nsource "$D/lib.sh"\n'],
+ ['neg-disagree', 'D="$(dirname "$0")"\nD="../elsewhere"\nsource "$D/lib.sh"\n'],
+ ['neg-self-ref', 'D="$D/x"\nsource "$D/lib.sh"\n'],
+ ['neg-depth-chain', 'A="$B/x"\nB="$C/y"\nC="$A/z"\nD="$(dirname "$0")"\nE="$D/1"\nF="$E/2"\nG="$F/3"\nH="$G/4"\nI="$H/5"\nsource "$I/lib.sh"\n'],
+ ['neg-unknown-subst', 'source "$(somecmd)/lib.sh"\n'],
+ ['neg-default-sep', 'source "${D:-a/b}/lib.sh"\n'],
+ ['neg-slashless', 'source lib.sh\n'],
+ ['neg-no-filename', 'D="$(dirname "$0")"\nsource "$D"\n'],
+ ['neg-dynamic-tail', 'W="x"\nD="$(dirname "$0")"\nsource "$D/$W/lib.sh"\n'],
+ ['neg-source-foreign', 'source ./tools/t.php\n'],
+ ['neg-env-startup', 'ENV=./env.sh bash ./tools/sib.sh\n'],
+ ];
+ for (const [name, body] of negatives) write(`bin/${name}.sh`, `#!/usr/bin/env bash\n${body}`);
+
+ // INTERPRETER-WRAPPER matrix — every form resolves to the executed script.
+ const wrappers: Array<[string, string]> = [
+ ['wrap-env-assigns', 'FOO=1 env ../tools/sib.sh\n'],
+ ['wrap-command', 'command ../tools/sib.sh\n'],
+ ['wrap-builtin-stack', 'builtin command ../tools/sib.sh\n'],
+ ['wrap-sudo-user', 'sudo -u alice ../tools/sib.sh\n'],
+ ['wrap-nohup', 'nohup ../tools/sib.sh\n'],
+ ['wrap-timeout-duration', 'timeout 30 ../tools/sib.sh\n'],
+ ['wrap-nice-adjust', 'nice -n 5 ../tools/sib.sh\n'],
+ ['wrap-stdbuf-mode', 'stdbuf -o 64K ../tools/sib.sh\n'],
+ ['wrap-exec-name', 'exec -a renamed ../tools/sib.sh\n'],
+ ['wrap-zsh', 'zsh ../tools/sib.sh\n'],
+ ['wrap-shell-variable', 'RUN=zsh\n$RUN ../tools/sib.sh\n'],
+ ['wrap-interpreter-opts', 'bash -l ../tools/sib.sh\n'],
+ // An interpreter's script argument must go through the same anchor
+ // tracing `source` uses. These arrive as wordsOfCommand's '\0' sentinel
+ // because they are quoted, and were previously discarded outright.
+ ['wrap-interp-var-anchor', 'H="$(dirname "$0")"\nbash "$H/../tools/sib.sh"\n'],
+ ['wrap-interp-var-split', 'H="$(dirname "$0")"\nbash "$H"/../tools/sib.sh\n'],
+ ['wrap-interp-flag-then-var', 'H="$(dirname "$0")"\nbash -x "$H/../tools/sib.sh"\n'],
+ ['wrap-interp-ddash', 'H="$(dirname "$0")"\nbash -- "$H/../tools/sib.sh"\n'],
+ ['wrap-interp-stdin', 'H="$(dirname "$0")"\nbash -s < "$H/../tools/sib.sh"\n'],
+ ['foreign-python', 'python3 ../tools/t.py\n'],
+ ['foreign-node', 'node ../tools/t.js\n'],
+ ['foreign-php', 'php ../tools/t.php\n'],
+ ['foreign-absolute-php', '/usr/bin/php ../tools/t.php\n'],
+ ];
+ for (const [name, body] of wrappers) write(`bin/${name}.sh`, `#!/usr/bin/env bash\n${body}`);
+
+ // Wrapper shapes that must return null rather than guess.
+ const wrapperNegatives: Array<[string, string]> = [
+ ['wrapneg-bare-env', 'env\n'],
+ ['wrapneg-unenum-opt', 'sudo --frobnicate ../tools/sib.sh\n'],
+ // `-c` takes a COMMAND STRING and `-s` reads from stdin: neither has a
+ // script-path argument, so a path-looking string inside them must not
+ // become a relation. Short options bundle, hence `-ec`.
+ ['wrapneg-interp-c-string', 'bash -c "cd /tmp && ../tools/sib.sh"\n'],
+ ['wrapneg-interp-bundled-c', 'bash -ec "../tools/sib.sh"\n'],
+ // A bare name is resolved by bash against the runtime cwd/PATH, not the
+ // script's directory, so it stays unresolved on purpose.
+ ['wrapneg-interp-bare-name', 'bash sib.sh\n'],
+ ['wrapneg-python-c-string', 'python3 -c "print(1)"\n'],
+ ];
+ for (const [name, body] of wrapperNegatives)
+ write(`bin/${name}.sh`, `#!/usr/bin/env bash\n${body}`);
+
+ const startupNegatives: Array<[string, string]> = [
+ ['startup-rcfile', 'bash --rcfile ../env.sh ../tools/sib.sh\n'],
+ ['startup-initfile', 'bash --init-file ../env.sh ../tools/sib.sh\n'],
+ ['startup-env', 'ENV=../env.sh sh ../tools/sib.sh\n'],
+ ['startup-unresolved', 'BASH_ENV="$UNKNOWN_ENV" bash ./tools/sib.sh\n'],
+ ];
+ for (const [name, body] of startupNegatives)
+ write(`bin/${name}.sh`, `#!/usr/bin/env bash\n${body}`);
+
+ // WORKING-DIRECTORY suppression set.
+ write('bin/wd-suppressed.sh', '#!/usr/bin/env bash\ncd ..\nsource lib/lib.sh\n');
+ write(
+ 'bin/wd-anchored-after-cd.sh',
+ '#!/usr/bin/env bash\ncd ..\nsource "$(dirname "$0")/../lib/lib.sh"\n'
+ );
+ write(
+ 'bin/wd-cd-inside-subst.sh',
+ '#!/usr/bin/env bash\nB=$(cd "$(dirname "$0")/.." && pwd)\nsource "$B/lib/lib.sh"\n'
+ );
+ write('bin/chroot-run.sh', '#!/usr/bin/env bash\nchroot ../newroot /run.sh\n');
+ write('bin/nsenter-run.sh', '#!/usr/bin/env bash\nnsenter -t 123 /run.sh\n');
+ write('bin/bwrap-run.sh', '#!/usr/bin/env bash\nbwrap --bind ./src /app -- /app/build.sh\n');
+ write('bin/local-stdin.sh', '#!/usr/bin/env bash\nbash < ../scripts/x.sh\n');
+ write('bin/ssh-stdin.sh', '#!/usr/bin/env bash\nssh host \'bash -s\' < ../scripts/x.sh\n');
+ write('bin/docker-stdin.sh', '#!/usr/bin/env bash\ndocker run -i image bash -s < ../scripts/x.sh\n');
+ write('bin/ssh-subst.sh', '#!/usr/bin/env bash\nssh host "$(cat ../scripts/x.sh)"\n');
+ write('bin/ssh-remote-path.sh', '#!/usr/bin/env bash\nssh host ../scripts/x.sh\n');
+ write('bin/docker-remote-path.sh', '#!/usr/bin/env bash\ndocker run image ../scripts/x.sh\n');
+
+ cg = CodeGraph.initSync(root);
+ await cg.indexAll();
+
+ files = new Map(cg.getNodesByKind('file').map((n) => [n.filePath, n]));
+ });
+
+ afterAll(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ const hasRelation = (fromRel: string, kind: 'imports' | 'references', toRel: string): boolean => {
+ const from = files.get(fromRel);
+ const to = files.get(toRel);
+ if (!from || !to) return false;
+ return cg
+ .getOutgoingEdges(from.id)
+ .some((e) => e.kind === kind && e.target === to.id);
+ };
+
+ it('resolves every positive sourcing anchor to lib/lib.sh', () => {
+ for (const name of [
+ 'bin/var-anchor.sh',
+ 'bin/inline-dirname.sh',
+ 'bin/cdprint-var.sh',
+ 'bin/cdprint-suffix.sh',
+ 'bin/bashsource-array.sh',
+ 'bin/zero-removal.sh',
+ 'bin/literal-rel.sh',
+ 'bin/agree-chain.sh',
+ 'bin/repo-root-anchor.sh',
+ ]) {
+ expect(hasRelation(name, 'imports', 'lib/lib.sh'), `${name} -> lib/lib.sh`).toBe(true);
+ }
+ });
+
+ it('records executions as references-kind relations to the executed script', () => {
+ for (const name of ['bin/exec-anchor.sh', 'bin/exec-compose.sh']) {
+ expect(hasRelation(name, 'references', 'tools/sib.sh'), `${name} -> tools/sib.sh`).toBe(true);
+ }
+ });
+
+ it('resolves a bare command only through a script-prepended PATH directory', () => {
+ expect(hasRelation('bin/path-prepend.sh', 'references', 'bin/bin/mytool')).toBe(true);
+ });
+
+ it('imports BASH_ENV and keeps the interpreter target as an execution reference', () => {
+ expect(hasRelation('bin/bash-env-startup.sh', 'imports', 'env.sh')).toBe(true);
+ expect(hasRelation('bin/bash-env-startup.sh', 'references', 'tools/sib.sh')).toBe(true);
+ });
+
+ it('does not mistake interactive-only or unresolved startup options for imports', () => {
+ for (const name of ['bin/startup-rcfile.sh', 'bin/startup-initfile.sh', 'bin/startup-env.sh', 'bin/startup-unresolved.sh']) {
+ const from = files.get(name)!;
+ expect(cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'imports')).toEqual([]);
+ }
+ });
+
+ it('yields no edge for any negative anchor case', () => {
+ const negativeNames = [
+ 'bin/neg-untraceable.sh',
+ 'bin/neg-param-default.sh',
+ 'bin/neg-conditional.sh',
+ 'bin/neg-fn-body.sh',
+ 'bin/neg-disagree.sh',
+ 'bin/neg-self-ref.sh',
+ 'bin/neg-depth-chain.sh',
+ 'bin/neg-unknown-subst.sh',
+ 'bin/neg-default-sep.sh',
+ 'bin/neg-slashless.sh',
+ 'bin/neg-no-filename.sh',
+ 'bin/neg-dynamic-tail.sh',
+ 'bin/neg-source-foreign.sh',
+ 'bin/neg-env-startup.sh',
+ ];
+ for (const name of negativeNames) {
+ const from = files.get(name);
+ expect(from, name).toBeDefined();
+ const rels = cg
+ .getOutgoingEdges(from!.id)
+ .filter((e) => e.kind === 'imports' || e.kind === 'references');
+ expect(rels, `${name} must emit no script relation`).toEqual([]);
+ }
+ });
+
+ it('sees through every enumerated wrapper form to the executed script', () => {
+ for (const name of [
+ 'bin/wrap-env-assigns.sh',
+ 'bin/wrap-command.sh',
+ 'bin/wrap-builtin-stack.sh',
+ 'bin/wrap-sudo-user.sh',
+ 'bin/wrap-nohup.sh',
+ 'bin/wrap-timeout-duration.sh',
+ 'bin/wrap-nice-adjust.sh',
+ 'bin/wrap-stdbuf-mode.sh',
+ 'bin/wrap-exec-name.sh',
+ 'bin/wrap-zsh.sh',
+ 'bin/wrap-shell-variable.sh',
+ 'bin/wrap-interpreter-opts.sh',
+ 'bin/wrap-interp-var-anchor.sh',
+ 'bin/wrap-interp-var-split.sh',
+ 'bin/wrap-interp-flag-then-var.sh',
+ 'bin/wrap-interp-ddash.sh',
+ 'bin/wrap-interp-stdin.sh',
+ ]) {
+ expect(hasRelation(name, 'references', 'tools/sib.sh'), `${name} -> tools/sib.sh`).toBe(true);
+ }
+ });
+
+ it('returns null rather than guessing on unenumerated wrapper shapes', () => {
+ for (const name of [
+ 'bin/wrapneg-bare-env.sh',
+ 'bin/wrapneg-unenum-opt.sh',
+ 'bin/wrapneg-interp-c-string.sh',
+ 'bin/wrapneg-interp-bundled-c.sh',
+ 'bin/wrapneg-interp-bare-name.sh',
+ 'bin/wrapneg-python-c-string.sh',
+ ]) {
+ const from = files.get(name)!;
+ const rels = cg
+ .getOutgoingEdges(from.id)
+ .filter((e) => e.kind === 'imports' || e.kind === 'references');
+ expect(rels, `${name}`).toEqual([]);
+ }
+ });
+
+ it('resolves foreign interpreter launches to their indexed target files', () => {
+ expect(hasRelation('bin/foreign-python.sh', 'references', 'tools/t.py')).toBe(true);
+ expect(hasRelation('bin/foreign-node.sh', 'references', 'tools/t.js')).toBe(true);
+ expect(hasRelation('bin/foreign-php.sh', 'references', 'tools/t.php')).toBe(true);
+ expect(hasRelation('bin/foreign-absolute-php.sh', 'references', 'tools/t.php')).toBe(true);
+ });
+
+ it('suppresses cwd-dependent paths after a cd outside own-process constructs, but keeps anchored ones', () => {
+ expect(hasRelation('bin/wd-suppressed.sh', 'imports', 'lib/lib.sh')).toBe(false);
+ expect(hasRelation('bin/wd-anchored-after-cd.sh', 'imports', 'lib/lib.sh')).toBe(true);
+ expect(hasRelation('bin/wd-cd-inside-subst.sh', 'imports', 'lib/lib.sh')).toBe(true);
+ });
+
+ it('applies chroot remapping and refuses runtime-dependent nsenter roots', () => {
+ expect(hasRelation('bin/chroot-run.sh', 'references', 'newroot/run.sh')).toBe(true);
+ expect(hasRelation('bin/chroot-run.sh', 'references', 'run.sh')).toBe(false);
+ const nsenter = files.get('bin/nsenter-run.sh')!;
+ expect(cg.getOutgoingEdges(nsenter.id).filter((e) => e.kind === 'imports' || e.kind === 'references')).toEqual([]);
+ expect(hasRelation('bin/bwrap-run.sh', 'references', 'bin/src/build.sh')).toBe(true);
+ });
+
+ it('keeps local stdin redirections while refusing remote command operands', () => {
+ for (const name of ['bin/local-stdin.sh', 'bin/ssh-stdin.sh', 'bin/docker-stdin.sh', 'bin/ssh-subst.sh']) {
+ expect(hasRelation(name, 'references', 'scripts/x.sh')).toBe(true);
+ }
+ for (const name of ['bin/ssh-remote-path.sh', 'bin/docker-remote-path.sh']) {
+ expect(hasRelation(name, 'references', 'scripts/x.sh')).toBe(false);
+ }
+ });
+
+ it('keeps every enumerated case in exactly one set', () => {
+ const scripts = fs.readdirSync(path.join(root, 'bin'));
+ expect(scripts.length).toBe(17 + 16 + 21 + 8 + 10);
+ });
+});
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index ad0ba2374..36b6f05ba 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -55,6 +55,15 @@ describe('Language Detection', () => {
expect(detectLanguage('lib.rs')).toBe('rust');
});
+ it('should detect shell script extensions as bash', () => {
+ expect(detectLanguage('deploy.sh')).toBe('bash');
+ expect(detectLanguage('rc.bash')).toBe('bash');
+ expect(detectLanguage('job.ksh')).toBe('bash');
+ expect(detectLanguage('job.dash')).toBe('bash');
+ expect(detectLanguage('suite.bats')).toBe('bash');
+ expect(detectLanguage('interactive.zsh')).not.toBe('bash');
+ });
+
it('should detect Java files', () => {
expect(detectLanguage('Main.java')).toBe('java');
});
@@ -394,6 +403,88 @@ in
});
});
+describe('Bash Extraction', () => {
+ it('should extract functions, top-level variables and constants', () => {
+ const code = `
+#!/usr/bin/env bash
+APP_NAME="fixture"
+readonly LIB_VERSION="2.0"
+declare -r MAX=5
+export PATH="/usr/bin"
+greet() { printf 'hello\\n'; }
+function kwfn { greet; }
+`;
+ const result = extractFromSource('tool.sh', code);
+
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'APP_NAME')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'LIB_VERSION')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'MAX')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'PATH')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'greet')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'kwfn')).toBeDefined();
+
+ const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+ expect(calls).toContain('printf');
+ expect(calls.filter((name) => name === 'greet')).toHaveLength(1);
+ });
+
+ it('surfaces only the declarations inside a function that assign in the global scope', () => {
+ // Which builtin localizes is not guessable — it was measured:
+ // f() { declare X=1; }; f -> X unset afterwards (local)
+ // f() { readonly X=1; }; f -> X=1 afterwards (global)
+ // `declare`/`declare -r` localize exactly like `local`, so they are
+ // suppressed for the same reason; `declare -g`, `readonly` and `export`
+ // assign globally even inside a function, so they are kept. No other
+ // language in the index emits function-local variables either.
+ const code = `
+#!/usr/bin/env bash
+TOP="kept"
+run_all() {
+ local inner="declared"
+ inner="reassigned"
+ declare d_local="local"
+ declare -r d_local_ro="local"
+ declare -g d_global="global"
+ declare -gr d_global_ro="global"
+ typeset t_local="local"
+ typeset -g t_global="global"
+ typeset -gr t_global_ro="global"
+ export EXPORTED="surfaced"
+ readonly FROZEN="surfaced"
+ global GVAL="command-form"
+}
+`;
+ const result = extractFromSource('tool.sh', code);
+ const names = result.nodes.map((n) => n.name);
+
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'TOP')).toBeDefined();
+
+ // Function-local, in every spelling: absent.
+ for (const local of ['inner', 'd_local', 'd_local_ro', 't_local']) {
+ expect(names, `${local} is function-local and must not be a node`).not.toContain(local);
+ }
+
+ // Assigned in the global scope despite sitting inside the function: present.
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'd_global')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'd_global_ro')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 't_global')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 't_global_ro')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'EXPORTED')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'FROZEN')).toBeDefined();
+ });
+
+ it('keeps a top-level declare, which is not function-local', () => {
+ const code = `
+#!/usr/bin/env bash
+declare TOP_DECLARE=3
+declare -r TOP_DECLARE_RO=4
+`;
+ const result = extractFromSource('tool.sh', code);
+ expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'TOP_DECLARE')).toBeDefined();
+ expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'TOP_DECLARE_RO')).toBeDefined();
+ });
+});
+
describe('TypeScript Extraction', () => {
it('should extract function declarations', () => {
const code = `
diff --git a/__tests__/git-hooks.test.ts b/__tests__/git-hooks.test.ts
index 4dfd80eb5..bb0478428 100644
--- a/__tests__/git-hooks.test.ts
+++ b/__tests__/git-hooks.test.ts
@@ -21,6 +21,8 @@ import {
function gitInit(dir: string): void {
execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
+ // Keep the fixture independent of a user's global core.hooksPath setting.
+ execFileSync('git', ['config', 'core.hooksPath', '.git/hooks'], { cwd: dir, stdio: 'ignore' });
}
function isExecutable(file: string): boolean {
diff --git a/__tests__/shebang-detection.test.ts b/__tests__/shebang-detection.test.ts
new file mode 100644
index 000000000..56d124140
--- /dev/null
+++ b/__tests__/shebang-detection.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { scanDirectory } from '../src/extraction';
+import { detectLanguage, isSourceFile } from '../src/extraction/grammars';
+import { looksLikeShellScript } from '../src/extraction/shebang';
+import { FileWatcher, __emitWatchEventForTests } from '../src/sync/watcher';
+
+const roots: string[] = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
+});
+
+describe('extensionless shell script detection', () => {
+ it('recognizes direct and env shell shebangs within the bounded first line', () => {
+ expect(looksLikeShellScript('#!/bin/bash\necho ok\n')).toBe(true);
+ expect(looksLikeShellScript('#!/bin/dash\necho ok\n')).toBe(true);
+ expect(looksLikeShellScript('#!/usr/bin/env -S bash -eu\necho ok\n')).toBe(true);
+ expect(looksLikeShellScript('#!/usr/bin/python3\nprint(1)\n')).toBe(false);
+ expect(looksLikeShellScript('echo no shebang\n')).toBe(false);
+ });
+
+ it('uses file content only when a root is supplied', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shebang-'));
+ roots.push(root);
+ fs.writeFileSync(path.join(root, 'deploy'), '#!/bin/sh\nhelper\n');
+
+ expect(isSourceFile('deploy')).toBe(false);
+ expect(isSourceFile('deploy', root)).toBe(true);
+ expect(detectLanguage('deploy', '#!/bin/sh\nhelper\n')).toBe('bash');
+ });
+
+ it('finds and parses extensionless shell scripts in the filesystem scan', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shebang-scan-'));
+ roots.push(root);
+ fs.writeFileSync(path.join(root, 'deploy'), '#!/usr/bin/env bash\nmain() { helper; }\n');
+ fs.writeFileSync(path.join(root, 'README'), 'not source\n');
+
+ expect(scanDirectory(root)).toEqual(['deploy']);
+ });
+
+ it('passes extensionless shell changes through the watcher filter', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shebang-watch-'));
+ roots.push(root);
+ fs.writeFileSync(path.join(root, 'deploy'), '#!/bin/sh\necho ok\n');
+ const sync = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 1 });
+ const watcher = new FileWatcher(root, sync, { inertForTests: true, debounceMs: 1 });
+ watcher.start();
+
+ __emitWatchEventForTests(root, 'deploy');
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ watcher.stop();
+
+ expect(sync).toHaveBeenCalledWith(['deploy']);
+ });
+});
diff --git a/assets/languages/bash.svg b/assets/languages/bash.svg
new file mode 100644
index 000000000..9df1b25fe
--- /dev/null
+++ b/assets/languages/bash.svg
@@ -0,0 +1,8 @@
+
diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md
index c61fb9f31..57b14d409 100644
--- a/docs/design/dynamic-dispatch-coverage-playbook.md
+++ b/docs/design/dynamic-dispatch-coverage-playbook.md
@@ -251,6 +251,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
| Python | Django ORM | QuerySet → SQL compiler | R | ✅ |
| Python | Django / DRF (views) | url → view → model | R + X | ✅ url→view (`path`/`url`/`as_view`) + **DRF `router.register`→ViewSet** (realworld S / wagtail M / saleor L); ORM QuerySet→SQL (prior work). 🔬 signals (`post_save`→receiver), DRF viewset CRUD actions (inherited), saleor GraphQL resolvers |
| Python | Flask / FastAPI | request → route → handler → dependency | R + X | ✅ **Flask: handler resolved across intervening decorators (`@login_required`) + stacked `@x.route` lines** (microblog S 6→27, redash L decorator routes 6/6); **FastAPI: empty-path router-root routes `@router.get("")` incl. multi-line** (realworld S 12→20 / Netflix dispatch L **290/290 100%**) + **bare-name builtin guard** — a handler named after a Python builtin method (`index`/`get`/`update`/`count`…) was filtered as a builtin and lost its route→handler edge. + **Flask-RESTful `add_resource(Resource,'/x')` → Resource class** (redash 6→**77**) + **tuple `methods=('GET',)`** (was mislabeled GET) + **broadened detection** (requirements/Pipfile/setup + subdir app-factory entrypoints — flask-realworld 0→**19**). 🔬 FastAPI `Depends()` dependency edges (light validation) |
+| Bash | POSIX shell / Bash scripts | entrypoint → sourced/helper script → function or variable | X + R | ✅ Bash grammar, function/variable extraction, shebang detection, shell-to-shell imports, wrapper and interpreter path resolution, source-closure reachability, and refusal boundaries. Extraction PASS on `kward/shunit2` (21 conventional shell files), `scop/bash-completion` (556), and `nvm-sh/nvm` (308 Bash-language files). Agent A/B signal is positive on shunit2 and nvm: indexed arms used 0–1 Reads and 1–5 explores, while blocked arms used 2–7 Reads; nvm was 20–21s indexed vs 36–50s blocked across two runs. The bash-completion dispatcher question was rejected as an invalid endpoint because its extensionless, shebang-less `bash_completion` file is intentionally not indexed. 🔬 large-tier evidence remains unavailable among the selected shell-dominant repos; `.zsh` is intentionally excluded. |
| Go | Gin / chi / gorilla/mux / net-http | request → route → handler → service; middleware chain (`Use`→`Next`) | S + X | ✅ **routes on ANY group var** (`v1.GET`, `PublicGroup.GET`) not just `r/router` (gin-vue-admin S→M 4→259 / realworld S / gitness L) — was missing all group-routed apps; named handlers resolve precisely. **gorilla/mux confirmed covered** by the any-receiver `HandleFunc`/`Handle` handling (subrouter-var `s.HandleFunc(...)` + namespaced handlers; `.Methods()` chain ignored). + **gin middleware-chain synthesizer** (`ginMiddlewareChainEdges`): gin runs its entire chain through one dynamic line — `(*Context).Next` does `c.handlers[c.index](c)`, a slice-index dispatch tree-sitter can't resolve, so `callees(Next)` dead-ended at the `len()` helper (`safeInt8`) and the agent rabbit-holed re-querying it. Find the dispatcher (a Go method invoking a `handlers` slice by index) and link it → every HandlerFunc registered via `.Use`/`.GET`/…/`.Handle`; gated on the dispatcher existing (inert on non-gin Go repos), named handlers only (closures skipped), capped. gin L: `callees(Next)` now surfaces `Logger`/`Recovery`/`ErrorLogger`+handlers (node count stable 2,544; 5 precise edges with `registeredAt` wiring sites). **Agent A/B (headless median-of-4, Opus 4.8): gin flipped from codegraph −58% cost / −129% time (the rabbit-hole, incl. a stray `Workflow` mis-fire on 2/4 WITH runs) → +7% cost / +35% tokens / +8% time / 38% tool calls, all 4 WITH runs clean (0 Read/Grep/Bash, no Workflow, no duplicate calls).** 🔬 inline `func(c){}` handlers (anonymous, body lost); subrouter/`PathPrefix` path-prefix not prepended (label only); gitness chi custom (26/321) |
| Go | GoFrame (standard router) | request-type `g.Meta` route → controller method (reflective `group.Bind`) | R (extract) + S | ✅ **GoFrame `g.Meta` route coverage** (#747) — extractor (`frameworks/goframe.ts`, detect `gogf/gf` in go.mod) turns each `` g.Meta `path:.. method:..` `` request-type tag into a `route` node (requires `path:`, so a response `mime:`-only `g.Meta` is skipped), encoding the **package-qualified** request type in qualifiedName. `goframeRouteEdges` synthesizer joins route → the controller method whose **signature** takes that request type — NOT by name (`DeptSearchReq` is served by `List`, `GetDictReq` by `GetDictData`) — keyed `pkg.Type` to separate the dozens of identical bare names a big app defines one-per-module (`cash.ListReq` vs `order.ListReq`), with an **addon-root tiebreak** so a cloned demo addon (`addons/hgexample/`) binds within itself and never cross-links to core. Validated: gf-demo-user S **7/7**, gfast M **65/68** (3 genuinely handler-less DbInit), hotgo L (697 files) **242/247 (98%), 100% precision** (0 non-controller handlers, 0 core/addon cross-binding); node count stable on re-index; surfaces in the handler's caller trail (`POST /dept/add` → `Add` via `goframe-route`). **Agent A/B (gfast M, sonnet/high, 2 runs/arm): WITH = 1 `codegraph_explore` / 0 Read / 0 Grep / ~20s / correct; WITHOUT = 7.5 Read avg + grep-hunting for the non-existent literal `/dept/add` string + re-reading sys_dept.go up to 12× / ~42s — reads eliminated, −83% tool calls, 2.1× faster, cost a wash; both arms reach the same correct call path.** 🔬 group prefix from reflective `Bind` not prepended (route shows the `g.Meta` path, not `/system/dept/list`); the 4×-cloned `index` sub-packages inside one addon are left unlinked (needs import-path resolution, not just package name) |
| Rust | Axum / actix / Rocket | request → route → handler | R + X | ✅ **Axum chained methods + namespaced handlers** — `.route("/x", get(h1).post(h2))` emitted only the first method+handler, and `get(mod::handler)` captured the module not the fn (realworld-axum S **12→19, 19/19**); balanced-paren scan + per-method nodes + last-`::`-segment handler. **Rocket attribute macros 550/556 (99%)** (Rocket repo L) — already strong. crates.io named axum routes resolve (6/8; rest are closures/var handlers; its API is mostly the utoipa `routes!` macro = frontier). Cargo-workspace module resolution (prior work). **actix builder API** `web::resource("/x").route(web::get().to(h))` / `.to(h)` / App `.route("/x", web::get().to(h))` (actix-examples **51→128 routes, 35→112 resolved**) — was the dominant actix style and fully missed (the handler is in `.to(h)`, not `get(h)`). 🔬 actix `web::scope("/api")` prefix (not prepended to nested resource paths) + anonymous `.to` closure handlers |
diff --git a/scripts/add-lang/bench.sh b/scripts/add-lang/bench.sh
index 172fe4064..a4e37913d 100755
--- a/scripts/add-lang/bench.sh
+++ b/scripts/add-lang/bench.sh
@@ -1,15 +1,16 @@
#!/usr/bin/env bash
# Add-lang benchmark for ONE repo:
-# clone -> wipe+index (with the codegraph on PATH) -> verify extraction ->
+# clone -> wipe+index (with the configured dev binary) -> verify extraction ->
# with/without retrieval A/B (reuses scripts/agent-eval/run-all.sh).
#
-# Assumes the codegraph dev build is already built + linked on PATH — the skill
-# runs `npm run build && ./scripts/local-install.sh` ONCE before looping repos.
+# Assumes the codegraph dev build is already built. Set CG_BIN to its entrypoint
+# so the benchmark never depends on, or changes, the maintainer's PATH.
# The A/B is skipped if extraction fails its critical checks (don't burn $ on a
# broken extractor); set FORCE_AB=1 to run it anyway.
#
# Usage: bench.sh "" [headless|tmux|all]
-# Env: CORPUS corpus dir (default /tmp/codegraph-corpus, shared with agent-eval)
+# Env: CG_BIN codegraph entrypoint (default: codegraph resolved on PATH)
+# CORPUS corpus dir (default /tmp/codegraph-corpus, shared with agent-eval)
set -uo pipefail
LANG_TOKEN="${1:?usage: bench.sh \"\" [mode]}"
@@ -23,10 +24,11 @@ AGENT_EVAL="$(cd "$HARNESS/../agent-eval" && pwd)"
CORPUS="${CORPUS:-/tmp/codegraph-corpus}"
REPO="$CORPUS/$NAME"
-command -v codegraph >/dev/null || { echo "no codegraph on PATH (build + ./scripts/local-install.sh first)"; exit 1; }
+CG_BIN="${CG_BIN:-$(command -v codegraph 2>/dev/null || true)}"
+[ -n "$CG_BIN" ] || { echo "no codegraph binary (set CG_BIN to the dev entrypoint)"; exit 1; }
echo "==================== add-lang bench: $NAME ($LANG_TOKEN) ===================="
-echo "codegraph: $(command -v codegraph) -> $(codegraph --version 2>/dev/null || echo '?')"
+echo "codegraph: $CG_BIN -> $($CG_BIN --version 2>/dev/null || echo '?')"
# 1. Ensure the repo (shallow clone, reuse if present).
mkdir -p "$CORPUS"
@@ -40,7 +42,7 @@ fi
# 2. Wipe + index with the binary under test.
echo "→ wiping .codegraph and indexing"
rm -rf "$REPO/.codegraph"
-( cd "$REPO" && codegraph init -i ) || { echo "indexing failed"; exit 1; }
+( cd "$REPO" && "$CG_BIN" init -i ) || { echo "indexing failed"; exit 1; }
# 3. Verify extraction (cheap guard before the paid A/B).
echo "→ verifying extraction"
diff --git a/scripts/add-lang/verify-extraction.mjs b/scripts/add-lang/verify-extraction.mjs
index bdb443e25..9786357b3 100755
--- a/scripts/add-lang/verify-extraction.mjs
+++ b/scripts/add-lang/verify-extraction.mjs
@@ -4,8 +4,8 @@
// can drive a write-extractor -> build -> re-check loop.
//
// Usage: node scripts/add-lang/verify-extraction.mjs
-// Reads `codegraph status --json` using whatever codegraph is on PATH,
-// so it reflects the binary that built the index.
+// Reads `codegraph status --json`; CG_BIN selects the binary that built
+// the index without requiring a PATH change.
//
// Exit codes: 0 = pass or soft-warn, 1 = critical fail, 2 = could not run.
@@ -19,7 +19,8 @@ if (!repo || !lang) {
let status;
try {
- const out = execFileSync('codegraph', ['status', repo, '--json'], { encoding: 'utf8' });
+ const cgParts = process.env.CG_BIN ? process.env.CG_BIN.trim().split(/\s+/) : ['codegraph'];
+ const out = execFileSync(cgParts[0], [...cgParts.slice(1), 'status', repo, '--json'], { encoding: 'utf8' });
status = JSON.parse(out);
} catch (e) {
console.error(`[verify] could not read codegraph status for ${repo}: ${e.message}`);
diff --git a/scripts/local-install.sh b/scripts/local-install.sh
index 847966940..33e0777ed 100755
--- a/scripts/local-install.sh
+++ b/scripts/local-install.sh
@@ -16,6 +16,17 @@ VERSION=$(node -p "require('./package.json').version")
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "${1:-}" = "--undo" ]; then
+ LINK="${HOME}/.local/bin/codegraph"
+ PREVIOUS="${LINK}.previous-target"
+ if [ -f "$PREVIOUS" ]; then
+ TARGET=$(<"$PREVIOUS")
+ if [ -e "$TARGET" ]; then
+ ln -sfn "$TARGET" "$LINK"
+ rm -f "$PREVIOUS"
+ echo "done: restored codegraph -> $(command -v codegraph || echo \"$LINK\")"
+ exit 0
+ fi
+ fi
echo "→ unlinking ${PKG}"
npm unlink -g "${PKG}" >/dev/null 2>&1 || true
echo "→ reinstalling published ${PKG}"
@@ -28,7 +39,23 @@ echo "→ building ${PKG} ${VERSION} (${BRANCH})"
npm run build
echo "→ linking globally"
-npm link
+NPM_PREFIX=$(npm prefix -g 2>/dev/null || true)
+NPM_BIN="${NPM_PREFIX}/bin"
+if [ -n "$NPM_PREFIX" ] && [ -d "$NPM_BIN" ] && [ -w "$NPM_BIN" ]; then
+ npm link
+else
+ LINK="${HOME}/.local/bin/codegraph"
+ PREVIOUS="${LINK}.previous-target"
+ mkdir -p "$(dirname "$LINK")"
+ if [ -L "$LINK" ]; then
+ printf '%s\n' "$(readlink -f "$LINK")" > "$PREVIOUS"
+ elif [ -e "$LINK" ]; then
+ echo "refusing to replace existing non-symlink ${LINK}" >&2
+ exit 1
+ fi
+ ln -sfn "$PWD/dist/bin/codegraph.js" "$LINK"
+ echo "npm global prefix is not writable; linked directly to ${LINK}"
+fi
LINKED=$(command -v codegraph || echo "(not on PATH)")
echo
diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts
index 84647c3e4..14dcb6101 100644
--- a/src/extraction/grammars.ts
+++ b/src/extraction/grammars.ts
@@ -10,6 +10,7 @@ import * as path from 'path';
import * as fsp from 'fs/promises';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
+import { looksLikeShellScript, looksLikeShellScriptFile } from './shebang';
export type GrammarLanguage = Exclude;
@@ -50,6 +51,7 @@ const WASM_GRAMMAR_FILES: Record = {
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
nix: 'tree-sitter-nix.wasm',
+ bash: 'tree-sitter-bash.wasm',
};
/**
@@ -111,6 +113,13 @@ export const EXTENSION_MAP: Record = {
'.vue': 'vue',
'.astro': 'astro',
'.r': 'r',
+ // .zsh excluded: zsh-only syntax yields ERROR trees, silently dropping
+ // every symbol inside them.
+ '.sh': 'bash',
+ '.bash': 'bash',
+ '.ksh': 'bash',
+ '.dash': 'bash',
+ '.bats': 'bash',
'.pas': 'pascal',
'.dpr': 'pascal',
'.dpk': 'pascal',
@@ -173,22 +182,28 @@ export const EXTENSION_MAP: Record = {
};
/**
- * Whether a file is one CodeGraph can parse, based purely on its extension.
- * This is the single source of truth for "should we index this file" — derived
- * from EXTENSION_MAP so parser support and indexing selection never drift.
+ * Whether a file is one CodeGraph can parse. Extensionless files are accepted
+ * only when `rootDir` is supplied and their bounded prefix has a shell shebang.
+ * This is the single source of truth for "should we index this file".
*
- * `overrides` is the project's validated custom extension → language map (from
- * `codegraph.json`); when present its extensions count as indexable in addition
- * to the built-ins. Omitting it is byte-identical to the zero-config behavior.
+ * `rootDirOrOverrides` keeps the pre-shebang two-argument API compatible while
+ * allowing scanners to provide the root needed for content-based detection.
*/
-export function isSourceFile(filePath: string, overrides?: Record): boolean {
+export function isSourceFile(
+ filePath: string,
+ rootDirOrOverrides?: string | Record,
+ overrides?: Record,
+): boolean {
+ const rootDir = typeof rootDirOrOverrides === 'string' ? rootDirOrOverrides : undefined;
+ const extensionOverrides = typeof rootDirOrOverrides === 'object' ? rootDirOrOverrides : overrides;
if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless
if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups
if (isErlangAppFile(filePath)) return true; // OTP `.app`/`.app.src` resource files
- const dot = filePath.lastIndexOf('.');
- if (dot < 0) return false;
- const ext = filePath.slice(dot).toLowerCase();
- return ext in EXTENSION_MAP || (!!overrides && ext in overrides);
+ const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
+ const dot = basename.lastIndexOf('.');
+ if (dot < 0) return rootDir ? looksLikeShellScriptFile(filePath, rootDir) : false;
+ const ext = basename.slice(dot).toLowerCase();
+ return ext in EXTENSION_MAP || (!!extensionOverrides && ext in extensionOverrides);
}
/**
@@ -289,6 +304,10 @@ export async function initGrammars(): Promise {
* the vendored wasm together.
*/
const VENDORED_WASM_LANGS: ReadonlySet = new Set([
+ // Bash: upstream tree-sitter-bash v0.25.1 prebuilt wasm (ABI 15), copied
+ // byte-identical from the npm tarball. The tree-sitter-wasms artifact (ABI
+ // 14) crashes the shared WASM heap on any `case` statement.
+ 'bash',
'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go',
@@ -478,13 +497,15 @@ export function detectLanguage(filePath: string, source?: string, overrides?: Re
// Play `conf/routes` has no grammar — route through the no-symbol path; the
// Play framework resolver extracts route nodes from it.
if (isPlayRoutesFile(filePath)) return 'yaml';
- const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
+ const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
+ const ext = basename.substring(basename.lastIndexOf('.')).toLowerCase();
// Shopify OS 2.0 JSON templates / section groups → the Liquid extractor (it
// links each section `"type"` to its `sections/.liquid`).
if (isShopifyLiquidJson(filePath)) return 'liquid';
// OTP `.app`/`.app.src` resource files — Erlang terms the grammar parses as
// top-level expressions (last-dot ext `.src` is too generic for the map).
if (isErlangAppFile(filePath)) return 'erlang';
+ if (!basename.includes('.') && source && looksLikeShellScript(source)) return 'bash';
const lang = (overrides && overrides[ext]) || EXTENSION_MAP[ext] || 'unknown';
// .h files could be C, C++, or Objective-C — check source content
@@ -693,8 +714,9 @@ export function getLanguageDisplayName(language: Language): string {
vbnet: 'Visual Basic .NET',
erlang: 'Erlang',
terraform: 'Terraform',
- arkts: 'ArkTS',
- unknown: 'Unknown',
+ arkts: 'ArkTS',
+ bash: 'Bash',
+ unknown: 'Unknown',
};
return names[language] || language;
}
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index 93be48352..e5427d3b6 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -26,6 +26,7 @@ import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './
import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer';
import { materializeKernelResult } from './kernel';
import { detectGeneratedFile } from './generated-detection';
+import { looksLikeShellScriptFile } from './shebang';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns, PROJECT_CONFIG_FILENAME } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
@@ -446,7 +447,7 @@ function collectIncludedFiles(
if (defaults.ignores(rel)) return;
if (!include.ignores(rel)) return;
if (exclude && exclude.ignores(rel)) return;
- if (!isSourceFile(rel, overrides)) return;
+ if (!isSourceFile(rel, rootDir, overrides)) return;
out.add(rel);
}
};
@@ -1156,10 +1157,8 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
continue;
}
- const filePath = normalizePath(prefix + rel);
- if (!isSourceFile(filePath, overrides)) continue;
-
if (statusCode.includes('D')) {
+ const filePath = normalizePath(prefix + rel);
// Deletions stay unfiltered: getChangedFiles acts on one only when the
// path is already tracked in the DB, where removal is always correct — and
// that lets a newly-excluded dir's stale rows clean themselves up. (#766)
@@ -1167,6 +1166,9 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
continue;
}
+ const filePath = normalizePath(prefix + rel);
+ if (!isSourceFile(rel, repoDir, overrides)) continue;
+
// Added (`??`) / modified files inside an excluded dir must not enter the
// index — match against the repo-relative path, same as the full scan. (#766)
if (ig.ignores(rel)) continue;
@@ -1218,7 +1220,7 @@ export function scanDirectory(
const files: string[] = [];
let count = 0;
for (const filePath of gitFiles) {
- if (isSourceFile(filePath, overrides)) {
+ if (isSourceFile(filePath, rootDir, overrides)) {
files.push(filePath);
count++;
onProgress?.(count, filePath);
@@ -1247,7 +1249,7 @@ export async function scanDirectoryAsync(
const files: string[] = [];
let count = 0;
for (const filePath of gitFiles) {
- if (isSourceFile(filePath, overrides)) {
+ if (isSourceFile(filePath, rootDir, overrides)) {
files.push(filePath);
count++;
onProgress?.(count, filePath);
@@ -1351,7 +1353,7 @@ function scanDirectoryWalk(
walk(fullPath, active);
}
} else if (stat.isFile()) {
- if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) {
+ if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, rootDir, overrides)) {
files.push(relativePath);
count++;
onProgress?.(count, relativePath);
@@ -1368,7 +1370,7 @@ function scanDirectoryWalk(
walk(fullPath, active);
}
} else if (entry.isFile()) {
- if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) {
+ if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, rootDir, overrides)) {
files.push(relativePath);
count++;
onProgress?.(count, relativePath);
@@ -1662,7 +1664,11 @@ export class ExtractionOrchestrator {
await new Promise(resolve => setImmediate(resolve));
// Detect needed languages and load grammars in the parse worker
- const neededLanguages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))];
+ const neededLanguages = [...new Set(files.map((f) => (
+ !f.slice(f.lastIndexOf('/') + 1).includes('.') && looksLikeShellScriptFile(f, this.rootDir)
+ ? 'bash'
+ : detectLanguage(f, undefined, overrides)
+ )))];
// .h files default to 'c' but may be C++ — ensure cpp grammar is loaded when c is needed
if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) {
neededLanguages.push('cpp');
@@ -2874,7 +2880,11 @@ export class ExtractionOrchestrator {
// Load only grammars needed for changed files
if (filesToIndex.length > 0) {
const overrides = loadExtensionOverrides(this.rootDir);
- const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))];
+ const neededLanguages = [...new Set(filesToIndex.map((f) => (
+ !f.slice(f.lastIndexOf('/') + 1).includes('.') && looksLikeShellScriptFile(f, this.rootDir)
+ ? 'bash'
+ : detectLanguage(f, undefined, overrides)
+ )))];
// .h files default to 'c' but may be C++ — ensure cpp grammar is loaded
if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) {
neededLanguages.push('cpp');
diff --git a/src/extraction/languages/bash.ts b/src/extraction/languages/bash.ts
new file mode 100644
index 000000000..d4513b1d7
--- /dev/null
+++ b/src/extraction/languages/bash.ts
@@ -0,0 +1,1154 @@
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import * as posix from 'node:path/posix';
+import { getNodeText } from '../tree-sitter-helpers';
+import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
+
+interface CwdState {
+ cwdChanged: boolean;
+ pathPrepends: string[];
+}
+
+const cwdStates = new WeakMap