diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d07fb3..aba8f06a0 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 **Shell / Bash** (`.sh`, `.bash`, `.zsh`, `.ksh`, `.bats`) — functions in both the `foo()` and `function foo` forms, call edges between them, and `source` / `.` file dependency edges. A shell codebase is mostly small files wired together by `source`, so this is where the graph pays: `codegraph callers log_hook_audit` finds every script that calls a library function through its `source` line, and `codegraph impact` gives the blast radius of editing one. Command words that can never be a function (`echo`, `printf`, `local`, and the rest of the builtins) are left out so the real edges are not buried, while calls to external tools (`git`, `jq`) are kept — those answer "which scripts shell out to this". Sourced paths are matched through the usual `source "$SCRIPT_DIR/lib/_log.sh"` expansion. File-scope variables and `readonly` constants are indexed; function-local `local x=…` is not. (#239) + - Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list. - `codegraph_explore` no longer re-sends source it already returned earlier in the same conversation. A file it has already shown you comes back as a short pointer — the path, the symbols and the exact line range, with confirmation that the file hasn't changed since — and the space that frees is spent on code you haven't seen yet, so a follow-up call covers new ground instead of repeating the last one. If a file was edited in between, its source is always shown again in full. Set `CODEGRAPH_EXPLORE_DEDUP=0` to turn this off. diff --git a/README.md b/README.md index 4f89a40e6..bd87b3b0b 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,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, CFML, COBOL, Solidity, Terraform/OpenTofu, Shell/Bash, 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 | @@ -822,6 +822,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) | +| Shell / Bash | `.sh`, `.bash`, `.zsh`, `.ksh`, `.bats` | Full support (both `foo()` and `function foo` definition forms, call edges on command words with shell builtins filtered out so real edges are not buried, `source`/`.` file dependency edges resolved through the usual `"$SCRIPT_DIR/lib/x.sh"` expansion, file-scope variables and `readonly` constants; function-local `local x=…` is deliberately not indexed) | | 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 6bc48032e..4c41a5d94 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -43,6 +43,13 @@ describe('Language Detection', () => { expect(detectLanguage('config.mjs')).toBe('javascript'); }); + it('should detect shell scripts', () => { + expect(detectLanguage('hooks/pre-commit.sh')).toBe('bash'); + expect(detectLanguage('lib/_log.bash')).toBe('bash'); + expect(detectLanguage('setup.zsh')).toBe('bash'); + expect(detectLanguage('test/run.bats')).toBe('bash'); + }); + it('should detect Python files', () => { expect(detectLanguage('main.py')).toBe('python'); }); @@ -11637,3 +11644,118 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => { expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true); }); }); + +describe('Shell (bash) Extraction', () => { + it('should extract both function definition forms', () => { + const code = `#!/usr/bin/env bash +log_info() { + printf '%s\\n' "$1" >&2 +} + +function log_error { + log_info "error: $*" + return 1 +} +`; + const result = extractFromSource('lib/_log.sh', code); + + expect(result.errors).toEqual([]); + const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fns).toContain('log_info'); + expect(fns).toContain('log_error'); + }); + + it('should extract calls to shell functions and external tools, but not builtins', () => { + const code = `#!/usr/bin/env bash +resolve_repo() { + git -C "$1" rev-parse --show-toplevel +} + +main() { + local repo + repo="$(resolve_repo "$@")" + echo "$repo" + printf '%s\\n' "$repo" +} +`; + const result = extractFromSource('main.sh', code); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + + expect(calls).toContain('resolve_repo'); + expect(calls).toContain('git'); + // Builtins can never resolve to a definition and would bury the real edges. + expect(calls).not.toContain('echo'); + expect(calls).not.toContain('printf'); + expect(calls).not.toContain('local'); + }); + + it('should extract `source` and `.` as imports, keeping the literal path tail', () => { + const code = `#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +source "$(dirname "\${BASH_SOURCE[0]}")/lib/_recover-cwd.sh" +. "$SCRIPT_DIR/lib/_log.sh" +source "\${SCRIPT_DIR}/lib/_git.sh" +`; + const result = extractFromSource('hook.sh', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + + expect(imports).toContain('lib/_recover-cwd.sh'); + expect(imports).toContain('lib/_log.sh'); + expect(imports).toContain('lib/_git.sh'); + }); + + it('should find a `source` inside a function body', () => { + const code = `#!/usr/bin/env bash +load() { + source "$HERE/lib/_late.sh" +} +`; + const result = extractFromSource('late.sh', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('lib/_late.sh'); + }); + + it('should extract file-scope variables and readonly constants, not locals', () => { + const code = `#!/usr/bin/env bash +TOP_LEVEL=1 +readonly CONST_X=5 +export EXPORTED_Y="a" + +f() { + local inner_a=1 + echo "$inner_a" +} +`; + const result = extractFromSource('vars.sh', code); + const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); + const consts = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name); + + expect(vars).toContain('TOP_LEVEL'); + expect(vars).toContain('EXPORTED_Y'); + expect(consts).toContain('CONST_X'); + expect(vars).not.toContain('inner_a'); + }); + + it('should parse a case statement without erroring', () => { + // The tree-sitter-wasms bash build (ABI 14) traps on `case` under + // web-tree-sitter 0.25 and takes the whole file with it; this pins the + // vendored ABI-15 grammar. + const code = `#!/usr/bin/env bash +route() { + case "$1" in + fast) run_fast ;; + *) run_slow ;; + esac +} +`; + const result = extractFromSource('route.sh', code); + expect(result.errors).toEqual([]); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toContain('run_fast'); + expect(calls).toContain('run_slow'); + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index d4127631d..443882681 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,6 +50,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', }; /** @@ -120,6 +121,15 @@ export const EXTENSION_MAP: Record = { '.scala': 'scala', '.sc': 'scala', '.lua': 'lua', + // Shell: one grammar covers sh/bash/ksh dialects, and zsh scripts parse + // close enough that functions and calls extract (zsh-only syntax degrades to + // ERROR nodes locally rather than failing the file). `.bats` is bats-core + // test suites, which are plain bash plus `@test` blocks. + '.sh': 'bash', + '.bash': 'bash', + '.zsh': 'bash', + '.ksh': 'bash', + '.bats': 'bash', '.luau': 'luau', '.m': 'objc', '.mm': 'objc', @@ -338,6 +348,12 @@ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ // kernel compiles the same-commit vendored C (codegraph-kernel/grammars/ // dart); crates.io tree-sitter-dart is a different-lineage fork (rejected). 'dart', + // Bash: the tree-sitter-wasms build is ABI 14 and traps on `case` statements + // under web-tree-sitter 0.25 (a missing dynamic-linking stub aborts the whole + // parse), so any real script dies on its first `case`. We vendor the prebuilt + // tree-sitter-bash.wasm from the tree-sitter-bash 0.25.1 npm package (MIT) — + // byte-identical to the npm tarball's artifact, ABI 15, parses `case` cleanly. + 'bash', ]); /** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */ @@ -654,6 +670,7 @@ export function getLanguageDisplayName(language: Language): string { vbnet: 'Visual Basic .NET', erlang: 'Erlang', terraform: 'Terraform', + bash: 'Shell', arkts: 'ArkTS', unknown: 'Unknown', }; diff --git a/src/extraction/languages/bash.ts b/src/extraction/languages/bash.ts new file mode 100644 index 000000000..959fb6a9e --- /dev/null +++ b/src/extraction/languages/bash.ts @@ -0,0 +1,212 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +// Node names follow the vendored ABI-15 grammar (tree-sitter-bash 0.25.1), NOT +// the tree-sitter-wasms build — see the VENDORED_WASM_LANGS note in grammars.ts. +// +// Shell has no declaration syntax beyond functions, so the graph shape is: +// function_definition -> function symbols +// command -> call edges (the callee is the command word) +// `source x` / `. x` -> import symbols + file dependency edges +// variable_assignment -> constants/variables at file scope +// There are no classes, structs, interfaces, enums or type aliases. + +/** The two spellings of shell's only import form. */ +const SOURCE_COMMANDS = new Set(['source', '.']); + +/** + * Builtins, keywords and coreutils that can never resolve to a shell function. + * + * Every command word becomes a `calls` reference, which is what makes + * `codegraph callers ` work across sourced files. Without this filter the + * dominant edge in any script is `echo`/`printf`/`local` — thousands of + * permanently unresolvable references per repo that bury the real ones. An + * EXTERNAL tool (`git`, `jq`, `curl`) is deliberately NOT filtered: it stays an + * unresolved reference, which is how "which scripts shell out to jq" is + * answerable at all. Only names a `function` definition can never legally take + * belong here. + */ +const BASH_BUILTINS = new Set([ + '.', ':', '[', '[[', 'alias', 'bg', 'bind', 'break', 'builtin', 'caller', + 'cd', 'command', 'compgen', 'complete', 'compopt', 'continue', 'declare', + 'dirs', 'disown', 'echo', 'enable', 'eval', 'exec', 'exit', 'export', + 'false', 'fc', 'fg', 'getopts', 'hash', 'help', 'history', 'jobs', 'kill', + 'let', 'local', 'logout', 'mapfile', 'popd', 'printf', 'pushd', 'pwd', + 'read', 'readarray', 'readonly', 'return', 'set', 'shift', 'shopt', + 'source', 'suspend', 'test', 'times', 'trap', 'true', 'type', 'typeset', + 'ulimit', 'umask', 'unalias', 'unset', 'wait', +]); + +/** A command word we can link on: a plain identifier, not an expansion. */ +const PLAIN_COMMAND_WORD = /^[A-Za-z_][A-Za-z0-9_-]*$/; + +/** Text of a `command` node's `name:` field, or null when it isn't a bare word. */ +function commandWord(node: SyntaxNode, source: string): string | null { + const name = getChildByField(node, 'name'); + if (!name || name.type !== 'command_name') return null; + const text = getNodeText(name, source).trim(); + // `"$CMD" arg` / `${runner} arg` / `$(pick) arg` have no static callee. + if (!text || text.includes('$')) return null; + return text; +} + +/** + * Callee name for a `command` node, or null when there is nothing to link. + * Exported for the `bash` branch of TreeSitterExtractor.extractCall. + */ +export function bashCallee(node: SyntaxNode, source: string): string | null { + const word = commandWord(node, source); + if (!word || BASH_BUILTINS.has(word)) return null; + // `./scripts/build.sh` and `/usr/bin/env` are invocations of a FILE, not of a + // function; the graph has no edge kind for them, so leave them out rather + // than mint a reference that can never resolve. + if (!PLAIN_COMMAND_WORD.test(word)) return null; + return word; +} + +/** + * Path a `source` / `.` command loads, reduced to the literal tail. + * + * Sourcing is almost always written through a variable + * (`source "$SCRIPT_DIR/lib/_log.sh"`), so the argument's leading expansion is + * dropped and the literal remainder kept: `lib/_log.sh`. That tail is what + * resolveBashSource matches against indexed file paths. + */ +export function bashSourcedPath(node: SyntaxNode, source: string): string | null { + const word = commandWord(node, source); + if (!word || !SOURCE_COMMANDS.has(word)) return null; + + const arg = getChildByField(node, 'argument'); + if (!arg) return null; + let text = getNodeText(arg, source).trim(); + if (!text) return null; + + // Strip one layer of quoting, then drop the leading expansion so only the + // literal tail is left. `"$(dirname "${BASH_SOURCE[0]}")/lib/_log.sh"` becomes + // `lib/_log.sh`. Cutting at the LAST `)`/`}` (rather than matching the + // expansion itself) is what survives the nested-quote form above, which no + // flat regex parses. + text = text.replace(/^["']/, '').replace(/["']$/, ''); + const lastClose = Math.max(text.lastIndexOf(')'), text.lastIndexOf('}')); + if (lastClose >= 0) text = text.slice(lastClose + 1); + else text = text.replace(/^\$[A-Za-z_][A-Za-z0-9_]*/, ''); + text = text.replace(/^["']/, '').replace(/["']$/, ''); + text = text.replace(/^\/+/, '').replace(/^\.\//, ''); + // Anything still carrying an expansion, or a bare filename with no literal + // left, has no static target to resolve. + if (!text || text.includes('$') || text.includes('"') || text.includes("'")) return null; + return text; +} + +/** Depth-first walk of every node in a subtree. */ +function walk(node: SyntaxNode, visit: (n: SyntaxNode) => void): void { + visit(node); + for (const child of node.namedChildren) walk(child, visit); +} + +/** + * `readonly X=1` / `declare -r X=1` are the only constant forms shell has; + * `local`, `export`, `declare` and a bare assignment are all mutable. + */ +/** True when no enclosing `function_definition` wraps this node. */ +function isFileScope(node: SyntaxNode): boolean { + for (let p = node.parent; p; p = p.parent) { + if (p.type === 'function_definition') return false; + } + return true; +} + +function isConstantAssignment(assignment: SyntaxNode, source: string): boolean { + const parent = assignment.parent; + if (!parent || parent.type !== 'declaration_command') return false; + const keyword = getNodeText(parent, source).trimStart().split(/\s+/, 3); + if (keyword[0] === 'readonly') return true; + return (keyword[0] === 'declare' || keyword[0] === 'typeset') && keyword[1] === '-r'; +} + +export const bashExtractor: LanguageExtractor = { + functionTypes: ['function_definition'], + // Shell has no aggregate types at all. + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + // `source` is a command, not a statement — emitted from the visitNode hook. + importTypes: [], + // Handled by the `bash` branch of extractCall so builtins can be filtered; + // listing the type here is what routes command nodes to it in BOTH walkers + // (the top-level walk and visitFunctionBody). + callTypes: ['command'], + // Assignments are emitted from the visitNode hook instead, so the hook can + // drop function-local `local x=…` (see isFileScope): shell locals are not + // addressable symbols, and in a real script they outnumber functions ~6:1. + variableTypes: [], + nameField: 'name', + bodyField: 'body', + // Shell functions take no declared parameters ($1, $2, … are positional). + paramsField: 'parameters', + + // `foo() { … }` and `function foo { … }` are one node type; the signature is + // just the name, so show the form the file actually uses. + getSignature: (node, source) => { + const name = getChildByField(node, 'name'); + if (!name) return undefined; + const declared = getNodeText(node, source).slice(0, 200); + return declared.startsWith('function') ? `function ${getNodeText(name, source)}` : `${getNodeText(name, source)}()`; + }, + + visitNode: (node, ctx) => { + const source = ctx.source; + + // One whole-file scan for `source` / `.`, done when the walker reaches the + // root. Sourcing inside a function body is the common idiom in hook + // libraries, and function bodies are walked by visitFunctionBody (which + // never calls this hook) — a per-node import branch would miss them. + if (node.type === 'program') { + const parentId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const seen = new Set(); + walk(node, (n) => { + if (n.type !== 'command') return; + const modulePath = bashSourcedPath(n, source); + if (!modulePath || seen.has(modulePath)) return; + seen.add(modulePath); + ctx.createNode('import', modulePath, n, { + signature: getNodeText(n, source).trim().slice(0, 100), + }); + if (parentId) { + ctx.addUnresolvedReference({ + fromNodeId: parentId, + referenceName: modulePath, + referenceKind: 'imports', + line: n.startPosition.row + 1, + column: n.startPosition.column, + }); + } + }); + return false; + } + + // File-scope `X=1`, `export X=1`, `readonly X=1`. + if (node.type === 'variable_assignment') { + // A command prefix (`FOO=bar cmd`) is an argument to that command, and a + // function-local is not a symbol anyone queries. + if (node.parent?.type === 'command') return false; + if (!isFileScope(node)) return false; + const name = getChildByField(node, 'name'); + if (!name) return false; + const varName = getNodeText(name, source).trim(); + if (!varName) return false; + const kind = isConstantAssignment(node, source) ? 'constant' : 'variable'; + const declaration = node.parent?.type === 'declaration_command' ? node.parent : node; + ctx.createNode(kind, varName, declaration, { + signature: getNodeText(declaration, source).split('\n')[0]?.slice(0, 120), + }); + return false; + } + + return false; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..b1d7d4221 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { bashExtractor } from './bash'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + bash: bashExtractor, }; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8d71d7f18..60246eb0d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -22,6 +22,7 @@ import { isGeneratedFile } from './generated-detection'; import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs } from './languages/c-cpp'; +import { bashCallee } from './languages/bash'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -3757,6 +3758,27 @@ export class TreeSitterExtractor { return; } + // Shell: every `command` node is a call site, and the callee is the command + // word itself (`log_info "x"` → log_info). The generic path below would read + // namedChild(0), which is the variable_assignment in a `FOO=1 cmd` prefix, + // and it cannot drop builtins — `echo`/`printf`/`local` outnumber real calls + // by an order of magnitude in shell and would bury them. bashCallee does + // both; it returns null when there is nothing linkable (an expansion callee + // like `"$RUNNER" x`, a path invocation, or a builtin). + if (this.language === 'bash') { + const callee = bashCallee(node, this.source); + if (callee) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: callee, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + return; + } + // Erlang: a local call is `call(expr: atom, args)`; a remote call nests it // under `remote(module: remote_module, fun: call)` — the module qualifier // lives on the PARENT. Remote calls are emitted as `mod::fn`, which is diff --git a/src/extraction/wasm/tree-sitter-bash.wasm b/src/extraction/wasm/tree-sitter-bash.wasm new file mode 100644 index 000000000..4e4198c95 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-bash.wasm differ diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index df15579d5..bfec35fc5 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1485,6 +1485,13 @@ export function resolveViaImport( if (luaResult) return luaResult; } + // Shell `source` / `.`: the reference is a literal path tail, so match it + // against indexed files directly — there is no module system to go through. + if (ref.language === 'bash' && ref.referenceKind === 'imports') { + const bashResult = resolveBashSource(ref, context); + if (bashResult) return bashResult; + } + // Whole-module / namespace imports → link the importing file to the module // file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns // from './x'` (so a namespace touched only via a value-member read still @@ -1702,6 +1709,37 @@ function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): Reso return null; } +/** + * Shell `source lib/_log.sh` / `. "$DIR/lib/_log.sh"` — link the sourcing file + * to the sourced file. + * + * The reference name is the literal tail of the sourced path, because the + * directory is almost always an expansion the extractor cannot evaluate + * (`"$SCRIPT_DIR/lib/_log.sh"` → `lib/_log.sh`). Match that tail against + * indexed paths, preferring the candidate sharing the longest prefix with the + * sourcing file, which is what picks the right `_log.sh` in a repo that has + * several. + */ +function resolveBashSource(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const name = ref.referenceName; + if (!name) return null; + const byBasename = luaBasenameIndex(context); + const candidates = byBasename.get(name.split('/').pop() ?? '') ?? []; + const matches = candidates.filter((f) => f === name || f.endsWith('/' + name)); + if (matches.length === 0) return null; + const shared = (a: string, b: string): number => { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) i++; + return i; + }; + matches.sort((x, y) => shared(y, ref.filePath) - shared(x, ref.filePath)); + const best = matches[0]!; + if (best === ref.filePath) return null; + const fileNode = context.getNodesInFile(best).find((n) => n.kind === 'file'); + if (!fileNode) return null; + return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' }; +} + function resolveModuleImportToFile( ref: UnresolvedRef, imports: ImportMapping[], diff --git a/src/types.ts b/src/types.ts index 186f57adc..db451bd2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,7 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'bash', 'unknown', ] as const;