diff --git a/.claude/hooks/track-edits.sh b/.claude/hooks/track-edits.sh index 758db1328..f73652e8f 100644 --- a/.claude/hooks/track-edits.sh +++ b/.claude/hooks/track-edits.sh @@ -23,8 +23,27 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Use git worktree root so each worktree session has its own edit log -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +# Resolve the git worktree that actually owns the edited file, rather than +# the hook process's own ambient cwd. Edit/Write tool calls carry only an +# absolute file_path with no associated "current directory" state, so the +# hook's ambient cwd is not guaranteed to match the worktree the file lives +# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern +# guard-git.sh already uses on the read side of this same check. +# +# Walk up to the nearest existing ancestor directory first: Write can target +# a not-yet-created nested directory, and `git -C` requires an existing path. +SEARCH_DIR=$(dirname -- "$FILE_PATH") +while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do + SEARCH_DIR=$(dirname -- "$SEARCH_DIR") +done + +PROJECT_DIR="" +if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then + PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true +fi +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +fi LOG_FILE="$PROJECT_DIR/.claude/session-edits.log" # Normalize to relative path with forward slashes diff --git a/.claude/hooks/update-graph.sh b/.claude/hooks/update-graph.sh index ab8a15946..73ada4ddc 100644 --- a/.claude/hooks/update-graph.sh +++ b/.claude/hooks/update-graph.sh @@ -24,15 +24,35 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Only rebuild for source files codegraph tracks -# Skip docs, configs, test fixtures, and non-code files -case "$FILE_PATH" in - *.js|*.ts|*.tsx|*.jsx|*.py|*.go|*.rs|*.java|*.cs|*.php|*.rb|*.tf|*.hcl) - ;; - *) - exit 0 - ;; -esac +PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +# Only rebuild for source files codegraph tracks. +# Skip docs, configs, test fixtures, and non-code files. +# +# The real allowlist is EXTENSIONS (src/shared/constants.ts), derived from +# LANGUAGE_REGISTRY (src/domain/parser.ts) — the single source of truth for +# every language codegraph parses. `npm run build` snapshots it to +# dist/hook-extensions.txt (see scripts/gen-hook-extensions.mjs) so this +# hook can do a fast native bash/grep check on every Edit/Write instead of +# spawning a second Node process, or hand-copying the list, on every edit. +# +# The case statement below is only a fallback for before the first build +# (no dist/hook-extensions.txt yet). tests/unit/hook-extensions.test.ts +# fails if it ever drifts behind EXTENSIONS — keep it updated when +# LANGUAGE_REGISTRY gains a new extension. +EXT=".${FILE_PATH##*.}" +GENERATED_EXT_LIST="$PROJECT_DIR/dist/hook-extensions.txt" +if [ -f "$GENERATED_EXT_LIST" ]; then + grep -qxF "$EXT" "$GENERATED_EXT_LIST" || exit 0 +else + case "$EXT" in + .R|.bash|.c|.cc|.cjs|.clj|.cljc|.cljs|.cpp|.cs|.cu|.cuh|.cxx|.dart|.erl|.ex|.exs|.fs|.fsi|.fsx|.gemspec|.gleam|.go|.groovy|.gvy|.h|.hcl|.hpp|.hrl|.hs|.java|.jl|.js|.jsx|.kt|.kts|.lua|.m|.mjs|.ml|.mli|.php|.phtml|.py|.pyi|.r|.rake|.rb|.rs|.scala|.sh|.sol|.sv|.swift|.tf|.ts|.tsx|.v|.zig) + ;; + *) + exit 0 + ;; + esac +fi # Skip test fixtures — they're copied to tmp dirs anyway if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then @@ -40,7 +60,6 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then fi # Guard: codegraph DB must exist (project has been built at least once) -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" DB_PATH="$PROJECT_DIR/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 @@ -78,7 +97,7 @@ BUILD_OK=0 if command -v codegraph &>/dev/null; then codegraph build "$PROJECT_DIR" -d "$DB_PATH" $BUILD_FLAGS 2>/dev/null && BUILD_OK=1 || true else - node "${CLAUDE_PROJECT_DIR:-$PROJECT_DIR}/src/cli.js" build "$PROJECT_DIR" -d "$DB_PATH" $BUILD_FLAGS 2>/dev/null && BUILD_OK=1 || true + node "${CLAUDE_PROJECT_DIR:-$PROJECT_DIR}/dist/cli.js" build "$PROJECT_DIR" -d "$DB_PATH" $BUILD_FLAGS 2>/dev/null && BUILD_OK=1 || true fi # Update marker only if we did a full rebuild AND it succeeded diff --git a/.claude/skills/titan-gate/SKILL.md b/.claude/skills/titan-gate/SKILL.md index a660bcae5..fbd495838 100644 --- a/.claude/skills/titan-gate/SKILL.md +++ b/.claude/skills/titan-gate/SKILL.md @@ -45,6 +45,8 @@ codegraph check --staged --cycles --blast-radius 30 --boundaries -T --json This checks: manifesto rules, new cycle introduction, blast radius threshold, and architecture boundary violations. Exit code 0 = pass, 1 = fail. +The `blast-radius` predicate is call-graph-shape-aware: a touched function's absolute transitive-caller count only counts toward the threshold if the diff actually changed that function's own declaration line or the set of call targets referenced in its body. A function whose body changed without touching a call expression or its own signature (e.g. an inline literal replaced by a named constant) is exempt even with a high absolute caller count — that fan-in is pre-existing risk already accepted by the codebase, not risk this diff introduced. This is the authoritative blast-radius pass/fail — see Step 8. + Also run detailed impact analysis: ```bash @@ -302,9 +304,9 @@ Advisory — prevents jumping ahead and creating conflicts. ## Step 8 — Blast radius check -From diff-impact results: -- Transitive blast radius > 30 → FAIL -- Transitive blast radius > 15 → WARN +The FAIL/PASS decision comes from Step 1's `codegraph check --staged --blast-radius 30` predicate — do not re-derive it from diff-impact's raw numbers, which are absolute transitive-caller counts and are NOT shape-aware: +- Step 1's `blast-radius` predicate failed → FAIL (a touched function's own call graph shape genuinely changed and its transitive callers exceed 30) +- Step 1's `blast-radius` predicate passed but a changed function's raw `transitiveCallers` (from diff-impact) > 15 → WARN (worth a second look even though not gating — this may include functions Step 1 exempted as pre-existing fan-in) - Historically coupled file NOT staged? → WARN ("consider also updating X") --- diff --git a/.claude/skills/titan-grind/SKILL.md b/.claude/skills/titan-grind/SKILL.md index 56e79513b..f6605c53e 100644 --- a/.claude/skills/titan-grind/SKILL.md +++ b/.claude/skills/titan-grind/SKILL.md @@ -109,8 +109,9 @@ Forge shapes the metal. Grind smooths the rough edges. Your goal: find helpers t 12. **Capture dead-symbol baseline** (only if `grind.deadSymbolBaseline` is null): ```bash - codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const items=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:items.length,byRole:items.reduce((a,i)=>{a[i.role]=(a[i.role]||0)+1;return a},{})}));})" + codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})" ``` + `codegraph roles --json` returns `{ count, summary, symbols }` (not a bare array) — `summary` is already the per-role breakdown (e.g. `dead-leaf`, `dead-entry`, `dead-ffi`, `dead-unresolved`), so no manual reduce is needed. Store the total in `grind.deadSymbolBaseline`. Write `titan-state.json` immediately. 13. **Drift detection.** Compare `titan-state.json → mainSHA` against current origin/main: @@ -304,8 +305,8 @@ const tokens = helperName .slice(0, 3); const d=[];process.stdin.on('data',c=>d.push(c)); process.stdin.on('end',()=>{ try { - const items=JSON.parse(Buffer.concat(d)); - const candidates = items.filter(i => + const data=JSON.parse(Buffer.concat(d)); + const candidates = (data.symbols || []).filter(i => i.name !== helperName && tokens.some(t => i.name.toLowerCase().includes(t)) ); @@ -579,7 +580,7 @@ After all targets in the phase are processed: ```bash codegraph build -codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const items=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:items.length,byRole:items.reduce((a,i)=>{a[i.role]=(a[i.role]||0)+1;return a},{})}));})" +codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})" ``` Store in `grind.deadSymbolCurrent`. Write `titan-state.json`. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 12c59d4c4..25a3c6355 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -52,8 +52,11 @@ jobs: uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - model: claude-sonnet-4-6 - direct_prompt: | + # claude-code-action v1 dropped the `model` input; model selection now goes + # through `claude_args` (see anthropics/claude-code-action migration guide). + claude_args: | + --model claude-sonnet-4-6 + prompt: | ## Review this pull request You are reviewing a PR for **codegraph** — a local code dependency graph CLI that parses @@ -211,6 +214,9 @@ jobs: uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - model: claude-sonnet-4-6 + # claude-code-action v1 dropped the `model` input; model selection now goes + # through `claude_args` (see anthropics/claude-code-action migration guide). + claude_args: | + --model claude-sonnet-4-6 additional_permissions: | actions: read diff --git a/.gitignore b/.gitignore index 74b27941c..d8e9f3c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ crates/codegraph-core/*.node .claude/worktrees/ generated/DEPENDENCIES.md generated/DEPENDENCIES.json +token-benchmark.partial.json artifacts/ pkg/ target/ diff --git a/CLAUDE.md b/CLAUDE.md index cb147dac9..32ab58f67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ npm run test:coverage # Coverage report npx vitest run tests/parsers/javascript.test.ts # Single test file npx vitest run -t "finds cycles" # Single test by name npm run build:wasm # Rebuild WASM grammars from devDeps (built automatically on npm install) +npm run doctor # Check this worktree for a stale native binary / missing WASM grammars (runs automatically via pretest) ``` **Linter/Formatter:** [Biome](https://biomejs.dev/) — config in `biome.json`, scoped to `src/` and `tests/`. @@ -103,6 +104,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `shared/paginate.ts` | Pagination helpers for bounded query results | | **`infrastructure/`** | **Platform and I/O plumbing** | | `infrastructure/config.ts` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | +| `infrastructure/doctor.ts` | Environment health checks — stale `better-sqlite3` native ABI, incomplete `grammars/`; see `npm run doctor` | | `infrastructure/logger.ts` | Structured logging (`warn`, `debug`, `info`, `error`) | | `infrastructure/native.ts` | Native napi-rs addon loader with WASM fallback | | `infrastructure/registry.ts` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | @@ -126,7 +128,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `features/boundaries.ts` | Architecture boundary rules with onion architecture preset | | `features/cfg.ts` | Control-flow graph generation | | `features/check.ts` | CI validation predicates (cycles, complexity, blast radius, boundaries) | -| `features/communities.ts` | Louvain community detection, drift analysis (delegates to `graph/` subsystem) | +| `features/communities.ts` | Leiden community detection, drift analysis (delegates to `graph/` subsystem) | | `features/complexity.ts` | Cognitive, cyclomatic, Halstead, MI computation from AST | | `features/dataflow.ts` | Dataflow analysis | | `features/export.ts` | Graph export orchestration: loads data from DB, delegates to `presentation/` serializers | @@ -146,7 +148,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `presentation/sequence-renderer.ts` | Mermaid sequence diagram rendering | | `presentation/table.ts`, `result-formatter.ts`, `colors.ts` | CLI table formatting, JSON/NDJSON output, color constants | | **`graph/`** | **Unified graph model** | -| `graph/` | `CodeGraph` class (`model.ts`), algorithms (Tarjan SCC, Louvain, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) | +| `graph/` | `CodeGraph` class (`model.ts`), algorithms (Tarjan SCC, Leiden, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) | | **`mcp/`** | **MCP server** | | `mcp/` | MCP server exposing graph queries to AI agents; single-repo by default, `--multi-repo` to enable cross-repo access | | `ast-analysis/` | Unified AST analysis framework: shared DFS walker (`visitor.ts`), engine orchestrator (`engine.ts`), extracted metrics (`metrics.ts`), and pluggable visitors for complexity, dataflow, and AST-store | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6d883298..4d527a42f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,17 @@ npm install # also installs git hooks via husky npm test # run the full test suite ``` -**Requirements:** Node.js >= 20 +**Requirements:** Node.js >= 22.12.0 (see `engines.node` in `package.json`) + +**Working in multiple git worktrees?** Each worktree gets its own untracked +`node_modules/` and `grammars/` — neither is shared via git — so every fresh +`git worktree add` needs its own `npm install`. A worktree set up before a +host Node upgrade, or where `npm install` was interrupted, can be left with a +`better-sqlite3` binary compiled for the wrong Node ABI or an incomplete +`grammars/` directory; both fail in confusing ways deep inside a build or test +run. Run `npm run doctor` to check (or `npm run doctor -- --fix` to repair +in place, scoped to the current worktree) — it also runs automatically before +`npm test` via `pretest`. ## Contributor License Agreement (CLA) @@ -48,28 +58,37 @@ installs two git hooks: ## Project Structure +Source is TypeScript under `src/`, compiled via `tsup`/`tsc`; the native engine +lives in `crates/codegraph-core/` (Rust, via napi-rs) and mirrors the `src/` +tree module-for-module. `src/` is organized by layer, not by language: + ``` src/ - cli.js # Commander CLI entry point - index.js # Programmatic API exports - builder.js # Graph building: file collection, parsing, import resolution - parser.js # tree-sitter WASM wrapper + symbol extractors per language - queries.js # Query functions: symbol search, file deps, impact analysis - embedder.js # Semantic search with @huggingface/transformers - db.js # SQLite schema and operations - mcp.js # MCP server for AI agent integration - cycles.js # Circular dependency detection - export.js # DOT / Mermaid / JSON graph export - watcher.js # Watch mode for incremental rebuilds - config.js # .codegraphrc.json loading - constants.js # EXTENSIONS and IGNORE_DIRS - -grammars/ # Pre-built .wasm grammar files (committed) -scripts/ # Build scripts (build-wasm.js) -tests/ # vitest test suite -docs/ # Extended documentation + cli.ts, cli/ # Commander CLI entry point + per-command modules + index.ts # Programmatic API exports + shared/ # Cross-cutting constants, error types, kind enums + infrastructure/ # Config loading, logging, native addon loader, doctor + db/ # SQLite schema and operations (better-sqlite3) + domain/ # Parsing, graph building, import resolution, queries + extractors/ # Per-language symbol extractors (WASM/TS side) + features/ # Composable feature modules (audit, complexity, dataflow, ...) + presentation/ # Output formatting + CLI command wrappers + graph/ # Unified CodeGraph model + algorithms + classifiers + mcp/ # MCP server for AI agent integration + ast-analysis/ # Shared AST walker + pluggable analysis visitors + +crates/codegraph-core/ # Native (Rust/napi-rs) engine — mirrors src/ layout +scripts/ # Build, benchmark, and release tooling (TypeScript) +tests/ # vitest test suite +docs/ # Extended documentation ``` +For the authoritative, actively-maintained module-by-module breakdown (what +each file does, key design decisions, the native↔TypeScript mirroring table), +see the **Architecture** section of [`CLAUDE.md`](CLAUDE.md) — it is kept in +sync with the codebase as part of every PR that changes structure, so it's a +better long-term reference than a second copy here. + ## Development Workflow 1. **Fork** the repository and clone your fork @@ -85,9 +104,12 @@ docs/ # Extended documentation npm test # Run all tests (vitest) npm run test:watch # Watch mode npm run test:coverage # Coverage report -npx vitest run tests/parsers/go.test.js # Single test file +npx vitest run tests/parsers/go.test.ts # Single test file npx vitest run -t "finds cycles" # Single test by name -npm run build:wasm # Rebuild WASM grammars +npm run build # Compile TypeScript (tsc) to dist/ +npm run typecheck # Type-check only, no emit +npm run build:wasm # Rebuild WASM grammars from devDependencies +npm run doctor # Check for a stale native binary / missing WASM grammars ``` ## Branch Naming Convention @@ -158,6 +180,7 @@ tests/ parsers/ # Language parser extraction (one file per language) search/ # Semantic search + embeddings benchmarks/resolution/ # Call resolution precision/recall (per-language fixtures) + # + tracer/ — dynamic call tracers used to validate fixtures fixtures/ # Sample projects used by tests ``` @@ -175,11 +198,11 @@ results in the PR description. | Benchmark | What it measures | When to run | |-----------|-----------------|-------------| -| `node scripts/benchmark.js` | Build speed (native vs WASM), query latency | Changes to `builder.js`, `parser.js`, `queries.js`, `resolve.js`, `db.js`, or the native engine | -| `node scripts/embedding-benchmark.js` | Search recall (Hit@1/3/5/10) across models | Changes to `embedder.js` or embedding strategies | -| `node scripts/query-benchmark.js` | Query depth scaling, diff-impact latency | Changes to `queries.js`, `resolve.js`, or `db.js` | -| `node scripts/incremental-benchmark.js` | Incremental build, import resolution throughput | Changes to `builder.js`, `resolve.js`, `parser.js`, or `journal.js` | -| `npx vitest run tests/benchmarks/resolution/` | Call resolution precision/recall per language | Changes to `build-edges.js`, `resolve.js`, `parser.js`, or any extractor | +| `npm run benchmark` | Build speed (native vs WASM), query latency | Changes to `domain/graph/builder/`, `domain/parser.ts`, `domain/queries.ts`, `domain/graph/resolve.ts`, `db/`, or the native engine | +| `node scripts/node-ts.js scripts/embedding-benchmark.ts` | Search recall (Hit@1/3/5/10) across models | Changes to `domain/search/` or embedding strategies | +| `node scripts/node-ts.js scripts/query-benchmark.ts` | Query depth scaling, diff-impact latency | Changes to `domain/queries.ts`, `domain/graph/resolve.ts`, or `db/` | +| `node scripts/node-ts.js scripts/incremental-benchmark.ts` | Incremental build, import resolution throughput | Changes to `domain/graph/builder/`, `domain/graph/resolve.ts`, `domain/parser.ts`, or `domain/graph/journal.ts` | +| `npx vitest run tests/benchmarks/resolution/` | Call resolution precision/recall per language | Changes to `domain/graph/builder/stages/build-edges.ts`, `domain/graph/resolve.ts`, `domain/parser.ts`, or any extractor | ### Resolution precision/recall benchmark @@ -204,7 +227,7 @@ drops below the configured thresholds for any language, the test fails. 1. Create `tests/benchmarks/resolution/fixtures//` with source files 2. Add an `expected-edges.json` manifest (see the JSON schema at `tests/benchmarks/resolution/expected-edges.schema.json`) -3. Add thresholds in `resolution-benchmark.test.js` → `THRESHOLDS` +3. Add thresholds in `resolution-benchmark.test.ts` → `THRESHOLDS` 4. The benchmark runner auto-discovers fixtures with an `expected-edges.json` ### How to report results @@ -215,10 +238,10 @@ your PR description: ```bash git stash && git checkout main -node scripts/benchmark.js > before.json +npm run benchmark > before.json git checkout - && git stash pop -node scripts/benchmark.js > after.json +npm run benchmark > after.json ``` In the PR, include a table like: @@ -260,8 +283,11 @@ generics, nested types): 1. Find the tree-sitter AST node type using the [tree-sitter playground](https://tree-sitter.github.io/tree-sitter/playground) -2. Add a `case` in the corresponding `extractSymbols()` function -3. Add a test case in `tests/parsers/.test.js` +2. Add a `case` in the corresponding `extractSymbols()` function in + `src/extractors/.ts` (WASM) — and the mirrored extractor in + `crates/codegraph-core/src/extractors/` (native) if the change affects both + engines, per the dual-engine parity requirement in `CLAUDE.md` +3. Add a test case in `tests/parsers/.test.ts` ### Harness Engineering (Claude Code Hooks) @@ -285,19 +311,30 @@ Documentation improvements are always welcome. The main docs live in: imports -> SQLite DB -> query/search **Key design decisions:** -- WASM grammars are pre-built and committed in `grammars/` — no native compilation needed at install time +- **Dual-engine:** native Rust parsing via napi-rs (`crates/codegraph-core/`), with automatic fallback to WASM (`--engine native|wasm|auto`, default `auto`). Both engines must produce identical results — see `CLAUDE.md` for the mirrored module layout - Optional dependencies (`@huggingface/transformers`, `@modelcontextprotocol/sdk`) are lazy-loaded -- Parsers that can't load fail gracefully — they log a warning and skip those files +- Non-required parsers (everything except JS/TS/TSX) fail gracefully if their WASM grammar is unavailable — they log a warning and skip those files - Import resolution uses a 6-level priority system with confidence scoring -- The `feat/rust-core` branch introduces an optional native Rust engine via napi-rs for 5-10x faster parsing, with automatic fallback to WASM +- Incremental builds track file hashes in the DB to skip unchanged files -**Database:** SQLite at `.codegraph/graph.db` with tables: `nodes`, `edges`, -`metadata`, `embeddings` +**Database:** SQLite at `.codegraph/graph.db`. See the **Database** entry in +`CLAUDE.md` for the current table list — it's kept up to date there rather +than duplicated here. + +This is a summary; for full detail (module-by-module responsibilities, +configuration system, credential resolution, MCP isolation model) see +`CLAUDE.md`, which is the actively-maintained reference. ## WASM Grammars -The `.wasm` files in `grammars/` are pre-built and committed. You only need to -rebuild them if you: +`.wasm` grammar files are **not** committed to git — they're built from +`devDependencies` into `grammars/` automatically via the `prepare` npm script +(`npm run build:wasm`), which runs on every `npm install`. Each git worktree +therefore needs its own `npm install` before its `grammars/` directory is +populated (see "Working in multiple git worktrees?" above; `npm run doctor` +detects a missing or incomplete `grammars/`). + +Rebuild manually if you: - Add a new language - Upgrade a `tree-sitter-*` devDependency version @@ -317,8 +354,8 @@ Use [GitHub Issues](https://github.com/optave/ops-codegraph-tool/issues) with: ## Code Style -- All source is plain JavaScript (ES modules) — no transpilation -- [Biome](https://biomejs.dev/) is used for linting and formatting (config in `biome.json`) +- Source is TypeScript (`src/`), compiled via `tsc`/`tsup`; run `npm run typecheck` to type-check without emitting +- [Biome](https://biomejs.dev/) is used for linting and formatting (config in `biome.json`, scoped to `src/` and `tests/`) - Run `npm run lint` to check and `npm run lint:fix` to auto-fix - The pre-commit hook runs the linter automatically - Use `const`/`let` (no `var`) diff --git a/crates/codegraph-core/src/ast_analysis/complexity.rs b/crates/codegraph-core/src/ast_analysis/complexity.rs index 9827b091f..1f7d80ff2 100644 --- a/crates/codegraph-core/src/ast_analysis/complexity.rs +++ b/crates/codegraph-core/src/ast_analysis/complexity.rs @@ -337,8 +337,15 @@ pub static PHP_RULES: LangRules = LangRules { switch_like_nodes: &["switch_statement"], }; +// tree-sitter-c's if_statement wraps its else branch in a real `else_clause` +// node (`if_statement condition consequence else_clause(else [if_statement | +// ])`) — confirmed by parsing `if (..) {..} else if (..) {..} +// else {..}` and inspecting the S-expression. This is Pattern A (JS/C#/Rust +// style: an else_clause node wraps either a nested if_statement for +// `else if` or the plain else body), NOT Pattern C (Go/Java style, where the +// `alternative` field holds the substatement directly with no wrapper node). pub static C_RULES: LangRules = LangRules { - branch_nodes: &["if_statement", "for_statement", "while_statement", "do_statement", "case_statement", "conditional_expression"], + branch_nodes: &["if_statement", "else_clause", "for_statement", "while_statement", "do_statement", "case_statement", "conditional_expression"], case_nodes: &["case_statement"], logical_operators: &["&&", "||"], logical_node_types: &["binary_expression"], @@ -346,14 +353,16 @@ pub static C_RULES: LangRules = LangRules { nesting_nodes: &["if_statement", "for_statement", "while_statement", "do_statement", "conditional_expression"], function_nodes: &["function_definition"], if_node_type: Some("if_statement"), - else_node_type: None, + else_node_type: Some("else_clause"), elif_node_type: None, - else_via_alternative: true, + else_via_alternative: false, switch_like_nodes: &["switch_statement"], }; +// Mirrors C_RULES: tree-sitter-cpp's if_statement uses the same else_clause +// wrapper (Pattern A), confirmed by parsing the same if/else-if/else shape. pub static CPP_RULES: LangRules = LangRules { - branch_nodes: &["if_statement", "for_statement", "for_range_loop", "while_statement", "do_statement", "case_statement", "conditional_expression", "catch_clause"], + branch_nodes: &["if_statement", "else_clause", "for_statement", "for_range_loop", "while_statement", "do_statement", "case_statement", "conditional_expression", "catch_clause"], case_nodes: &["case_statement"], logical_operators: &["&&", "||"], logical_node_types: &["binary_expression"], @@ -361,9 +370,9 @@ pub static CPP_RULES: LangRules = LangRules { nesting_nodes: &["if_statement", "for_statement", "for_range_loop", "while_statement", "do_statement", "catch_clause", "conditional_expression"], function_nodes: &["function_definition"], if_node_type: Some("if_statement"), - else_node_type: None, + else_node_type: Some("else_clause"), elif_node_type: None, - else_via_alternative: true, + else_via_alternative: false, switch_like_nodes: &["switch_statement"], }; @@ -382,11 +391,16 @@ pub static KOTLIN_RULES: LangRules = LangRules { switch_like_nodes: &["when_expression"], }; +// tree-sitter-swift, like tree-sitter-kotlin, splits && / || into distinct +// node types (conjunction_expression / disjunction_expression) rather than +// sharing one generic binary node — confirmed by parsing `a && b || a` and +// inspecting the S-expression. `logical_node_types: &["binary_expression"]` +// never matches either operator, so Swift && / || were never counted. pub static SWIFT_RULES: LangRules = LangRules { branch_nodes: &["if_statement", "for_in_statement", "while_statement", "repeat_while_statement", "catch_clause", "switch_entry", "ternary_expression", "guard_statement"], case_nodes: &["switch_entry"], logical_operators: &["&&", "||"], - logical_node_types: &["binary_expression"], + logical_node_types: &["conjunction_expression", "disjunction_expression"], optional_chain_type: Some("optional_chaining_expression"), nesting_nodes: &["if_statement", "for_in_statement", "while_statement", "repeat_while_statement", "catch_clause", "ternary_expression", "guard_statement"], function_nodes: &["function_declaration", "init_declaration"], @@ -413,7 +427,7 @@ pub static SCALA_RULES: LangRules = LangRules { }; pub static BASH_RULES: LangRules = LangRules { - branch_nodes: &["if_statement", "for_statement", "while_statement", "case_statement", "elif_clause"], + branch_nodes: &["if_statement", "else_clause", "for_statement", "while_statement", "case_statement", "elif_clause"], case_nodes: &["case_item"], logical_operators: &["&&", "||"], logical_node_types: &["binary_expression"], @@ -427,6 +441,44 @@ pub static BASH_RULES: LangRules = LangRules { switch_like_nodes: &["case_statement"], }; +// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node +// kinds (`elseif_statement`, `else_statement`) attached to the *same* +// `if_statement` via repeated `alternative:` fields — confirmed by parsing +// `if a then .. elseif b then .. else .. end` with tree-sitter-lua and +// inspecting the S-expression. Structurally identical to Python's +// elif_clause/else_clause (Pattern B), not JS's nested else_clause>if_statement +// (Pattern A) or Go's alternative-holds-nested-if (Pattern C) — so +// else_via_alternative: false, and neither elseif_statement nor else_statement +// is in nesting_nodes (they're siblings of the primary if, not separately +// nested branches). Mirrors `complexityLua` in `src/ast-analysis/rules/b3.ts`. +// +// binary_expression is Lua's single generic binary-op node (arithmetic, +// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as +// Ruby's `binary` node; handle_logical_op only fires when `node.child(1)` (the +// operator token) is in logical_operators, so comparisons/arithmetic sharing +// the same node kind are correctly ignored. +pub static LUA_RULES: LangRules = LangRules { + branch_nodes: &[ + "if_statement", + "elseif_statement", + "else_statement", + "for_statement", + "while_statement", + "repeat_statement", + ], + case_nodes: &[], + logical_operators: &["and", "or"], + logical_node_types: &["binary_expression"], + optional_chain_type: None, + nesting_nodes: &["if_statement", "for_statement", "while_statement", "repeat_statement"], + function_nodes: &["function_declaration"], + if_node_type: Some("if_statement"), + else_node_type: Some("else_statement"), + elif_node_type: Some("elseif_statement"), + else_via_alternative: false, + switch_like_nodes: &[], +}; + /// Look up complexity rules by language ID (matches `COMPLEXITY_RULES` keys in JS). pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> { match lang_id { @@ -444,6 +496,7 @@ pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> { "swift" => Some(&SWIFT_RULES), "scala" => Some(&SCALA_RULES), "bash" => Some(&BASH_RULES), + "lua" => Some(&LUA_RULES), _ => None, } } @@ -1022,6 +1075,34 @@ pub static BASH_HALSTEAD: HalsteadRules = HalsteadRules { skip_types: &[], }; +// Member/method access (`dot_index_expression` `.`, `method_index_expression` +// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated +// "call happened" token, so they're counted as compound operators — mirrors +// Python's ["call", "subscript", "attribute"]. The '.'/':' separator tokens +// are ALSO in operator_leaf_types (matching Python counting both "attribute" +// and "."), so member access contributes two distinct operator kinds, +// consistent with the other languages above. Mirrors `halsteadLua` in +// `src/ast-analysis/rules/b3.ts`. +pub static LUA_HALSTEAD: HalsteadRules = HalsteadRules { + operator_leaf_types: &[ + "+", "-", "*", "/", "//", "%", "^", "#", "..", + "==", "~=", "<=", ">=", "<", ">", "=", + "and", "or", "not", + "&", "|", "~", "<<", ">>", + ".", ",", ":", "::", ";", + "if", "then", "else", "elseif", "end", + "for", "while", "do", "repeat", "until", + "function", "local", "return", "break", "goto", "in", + ], + operand_leaf_types: &[ + "identifier", "number", "string_content", "true", "false", "nil", "...", + ], + compound_operators: &[ + "function_call", "bracket_index_expression", "dot_index_expression", "method_index_expression", + ], + skip_types: &[], +}; + /// Look up Halstead rules by language ID. pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> { match lang_id { @@ -1039,6 +1120,7 @@ pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> { "swift" => Some(&SWIFT_HALSTEAD), "scala" => Some(&SCALA_HALSTEAD), "bash" => Some(&BASH_HALSTEAD), + "lua" => Some(&LUA_HALSTEAD), _ => None, } } @@ -1056,6 +1138,7 @@ pub fn comment_prefixes(lang_id: &str) -> &'static [&'static str] { "swift" => &["//", "/*"], "scala" => &["//", "/*"], "bash" => &["#"], + "lua" => &["--"], _ => &["//", "/*", "*", "*/"], } } @@ -1604,4 +1687,327 @@ mod tests { assert_eq!(m.cognitive, 1); assert_eq!(m.cyclomatic, 2); } + + // ─── Lua tests (issue #1782) ──────────────────────────────────────────── + + fn compute_lua(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_lua::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &LUA_RULES).expect("no function found"); + compute_function_complexity(&func, &LUA_RULES) + } + + #[test] + fn lua_empty_function() { + let m = compute_lua("local function f() end"); + assert_eq!(m.cognitive, 0); + assert_eq!(m.cyclomatic, 1); + assert_eq!(m.max_nesting, 0); + } + + #[test] + fn lua_single_if() { + let m = compute_lua("local function f(x)\n if x > 0 then\n return 1\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_if_elseif_else() { + // elseif_statement/else_statement are siblings of if_statement via + // repeated `alternative:` fields (Pattern B, like Python's elif/else). + let m = compute_lua( + "local function f(x)\n if x > 0 then\n return 1\n elseif x < 0 then\n return -1\n else\n return 0\n end\nend", + ); + // if: +1 cog, +1 cyc; elseif: +1 cog, +1 cyc; else: +1 cog + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_numeric_for_loop() { + let m = compute_lua("local function f()\n for i = 1, 10 do\n print(i)\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_while_loop() { + let m = compute_lua("local function f(n)\n while n > 0 do\n n = n - 1\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_repeat_until_loop() { + let m = compute_lua("local function f(n)\n repeat\n n = n - 1\n until n <= 0\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_logical_operators() { + let m = compute_lua("local function f(a, b)\n if a and b then\n return 1\n end\nend"); + // if: +1 cog, +1 cyc; and: +1 cog, +1 cyc + assert_eq!(m.cognitive, 2); + assert_eq!(m.cyclomatic, 3); + } + + #[test] + fn lua_nested_if() { + let m = compute_lua( + "local function f(x, y)\n if x > 0 then\n if y > 0 then\n return 1\n end\n end\nend", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 2); + } + + // ─── C/C++ tests (issue #1923) ────────────────────────────────────────── + + fn compute_c(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_c::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &C_RULES).expect("no function found"); + compute_function_complexity(&func, &C_RULES) + } + + fn compute_cpp(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_cpp::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &CPP_RULES).expect("no function found"); + compute_function_complexity(&func, &CPP_RULES) + } + + #[test] + fn c_empty_function() { + let m = compute_c("int f(void) { return 0; }"); + assert_eq!(m.cognitive, 0); + assert_eq!(m.cyclomatic, 1); + assert_eq!(m.max_nesting, 0); + } + + #[test] + fn c_single_if() { + let m = compute_c("int f(int x) {\n if (x > 0) {\n return 1;\n }\n return 0;\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn c_if_elseif_else() { + // tree-sitter-c wraps the else branch in a real else_clause node + // (Pattern A, like JS/C#/Rust) — NOT Go/Java's alternative-field + // pattern. Confirmed by parsing and inspecting the S-expression. + let m = compute_c( + "int f(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}", + ); + // if: +1 cog, +1 cyc; else-if: +1 cog, +1 cyc; plain else: +1 cog + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn c_nested_if() { + let m = compute_c( + "int f(int x, int y) {\n if (x > 0) {\n if (y > 0) {\n return 1;\n }\n }\n return 0;\n}", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 2); + } + + #[test] + fn c_logical_operators() { + let m = compute_c("int f(int a, int b) { return a && b; }"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + } + + #[test] + fn cpp_if_elseif_else() { + let m = compute_cpp( + "int f(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn cpp_for_range_loop() { + let m = compute_cpp("void f(int xs[]) {\n for (int x : xs) {\n use(x);\n }\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + // ─── Kotlin tests (issue #1923) ───────────────────────────────────────── + + fn compute_kotlin(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_kotlin_sg::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &KOTLIN_RULES).expect("no function found"); + compute_function_complexity(&func, &KOTLIN_RULES) + } + + #[test] + fn kotlin_single_if() { + let m = compute_kotlin("fun f(x: Int): Int {\n if (x > 0) {\n return 1\n }\n return 0\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn kotlin_logical_operators() { + // Kotlin's grammar splits && / || into distinct node types + // (conjunction_expression / disjunction_expression) rather than + // sharing one generic binary node — both are listed in + // logical_node_types. + let m = compute_kotlin("fun f(a: Boolean, b: Boolean): Boolean {\n return a && b || a\n}"); + assert_eq!(m.cyclomatic, 3); + } + + #[test] + fn kotlin_when_expression() { + let m = compute_kotlin( + "fun f(x: Int): Int {\n return when (x) {\n 1 -> 1\n 2 -> 2\n else -> 0\n }\n}", + ); + // base 1 + when container (0, switch-like) + 3 when_entry cases (+1 + // each) = 4. + assert_eq!(m.cyclomatic, 4); + } + + // ─── Swift tests (issue #1923) ────────────────────────────────────────── + + fn compute_swift(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_swift::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &SWIFT_RULES).expect("no function found"); + compute_function_complexity(&func, &SWIFT_RULES) + } + + #[test] + fn swift_single_if() { + let m = compute_swift("func f(_ x: Int) -> Int {\n if x > 0 {\n return 1\n }\n return 0\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn swift_logical_operators() { + let m = compute_swift("func f(_ a: Bool, _ b: Bool) -> Bool {\n return a && b\n}"); + assert_eq!(m.cyclomatic, 2); + } + + // ─── Scala tests (issue #1923) ────────────────────────────────────────── + + fn compute_scala(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_scala::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &SCALA_RULES).expect("no function found"); + compute_function_complexity(&func, &SCALA_RULES) + } + + #[test] + fn scala_if_elseif_else() { + // tree-sitter-scala's if_expression exposes a real `alternative` + // field holding either a nested if_expression or a block — Pattern C + // (Go/Java style) applies cleanly here, unlike Kotlin/Swift. + let m = compute_scala( + "def f(x: Int): Int = {\n if (x > 0) {\n 1\n } else if (x < 0) {\n -1\n } else {\n 0\n }\n}", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn scala_match_expression() { + let m = compute_scala( + "def f(x: Int): Int = {\n x match {\n case 1 => 1\n case 2 => 2\n case _ => 0\n }\n}", + ); + // base 1 + match container (0, switch-like) + 3 case_clause cases + // (+1 each) = 4. + assert_eq!(m.cyclomatic, 4); + } + + // ─── Bash tests (issue #1923) ─────────────────────────────────────────── + + fn compute_bash(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_bash::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &BASH_RULES).expect("no function found"); + compute_function_complexity(&func, &BASH_RULES) + } + + #[test] + fn bash_single_if() { + let m = compute_bash("f() {\n if [ \"$1\" -gt 0 ]; then\n echo pos\n fi\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn bash_if_elif_else() { + // elif_clause/else_clause are flat siblings of if_statement, matching + // Python's elif_clause/else_clause pattern (Pattern B). + let m = compute_bash( + "f() {\n if [ \"$1\" -gt 0 ]; then\n echo pos\n elif [ \"$1\" -lt 0 ]; then\n echo neg\n else\n echo zero\n fi\n}", + ); + // if: +1 cog, +1 cyc; elif: +1 cog, +1 cyc; else: +1 cog + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn bash_logical_operators() { + // `&&` inside a `[[ ... ]]` extended test expression parses as a real + // binary_expression node (matching logical_node_types). `&&` used to + // chain separate `[ ] && [ ]` commands is a different grammar + // category (a `list` node joining two `test_command`s, not a + // binary_expression) and is not counted here — confirmed by parsing + // both forms and inspecting the S-expression. + let m = compute_bash("f() {\n if [[ \"$1\" && \"$2\" ]]; then\n echo yes\n fi\n}"); + assert_eq!(m.cyclomatic, 3); + } } diff --git a/crates/codegraph-core/src/db/connection.rs b/crates/codegraph-core/src/db/connection.rs index f75c34c3d..aca94537b 100644 --- a/crates/codegraph-core/src/db/connection.rs +++ b/crates/codegraph-core/src/db/connection.rs @@ -16,6 +16,11 @@ use crate::db::repository::edges::{self, EdgeRow}; use crate::domain::graph::builder::stages::insert_nodes::{self, FileHashEntry, InsertNodesBatch}; use crate::graph::classifiers::roles::{self, RoleSummary}; +/// Fallback `PRAGMA busy_timeout` (ms) used when the caller doesn't pass a +/// resolved value. Mirrors `DEFAULTS.db.busyTimeoutMs` in +/// `src/infrastructure/config.ts` — keep both in sync. +const DEFAULT_BUSY_TIMEOUT_MS: u32 = 5000; + // ── Migration DDL (mirrored from src/db/migrations.ts) ────────────────── struct Migration { @@ -483,8 +488,12 @@ pub struct NativeDatabase { impl NativeDatabase { /// Open a read-write connection to the database at `db_path`. /// Creates the file and parent directories if they don't exist. + /// + /// `busy_timeout_ms` mirrors `config.db.busyTimeoutMs` on the TS side + /// (`DEFAULTS.db.busyTimeoutMs` in `src/infrastructure/config.ts`); + /// defaults to `DEFAULT_BUSY_TIMEOUT_MS` when omitted. #[napi(factory)] - pub fn open_read_write(db_path: String) -> napi::Result { + pub fn open_read_write(db_path: String, busy_timeout_ms: Option) -> napi::Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_NO_MUTEX; @@ -498,12 +507,13 @@ impl NativeDatabase { // are not cache-coherent on Windows, leading to SQLITE_CORRUPT (#715). // Read-only connections keep mmap since they don't share a WAL file // with a concurrent writer from a different library. - conn.execute_batch( + let busy_timeout_ms = busy_timeout_ms.unwrap_or(DEFAULT_BUSY_TIMEOUT_MS); + conn.execute_batch(&format!( "PRAGMA journal_mode = WAL; \ PRAGMA synchronous = NORMAL; \ - PRAGMA busy_timeout = 5000; \ + PRAGMA busy_timeout = {busy_timeout_ms}; \ PRAGMA temp_store = MEMORY;", - ) + )) .map_err(|e| napi::Error::from_reason(format!("Failed to set pragmas: {e}")))?; Ok(Self { conn: SendWrapper::new(Some(conn)), @@ -512,17 +522,22 @@ impl NativeDatabase { } /// Open a read-only connection to the database at `db_path`. + /// + /// `busy_timeout_ms` mirrors `config.db.busyTimeoutMs` on the TS side + /// (`DEFAULTS.db.busyTimeoutMs` in `src/infrastructure/config.ts`); + /// defaults to `DEFAULT_BUSY_TIMEOUT_MS` when omitted. #[napi(factory)] - pub fn open_readonly(db_path: String) -> napi::Result { + pub fn open_readonly(db_path: String, busy_timeout_ms: Option) -> napi::Result { let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX; let conn = Connection::open_with_flags(&db_path, flags) .map_err(|e| napi::Error::from_reason(format!("Failed to open DB readonly: {e}")))?; conn.set_prepared_statement_cache_capacity(64); - conn.execute_batch( - "PRAGMA busy_timeout = 5000; \ + let busy_timeout_ms = busy_timeout_ms.unwrap_or(DEFAULT_BUSY_TIMEOUT_MS); + conn.execute_batch(&format!( + "PRAGMA busy_timeout = {busy_timeout_ms}; \ PRAGMA mmap_size = 268435456; \ PRAGMA temp_store = MEMORY;", - ) + )) .map_err(|e| napi::Error::from_reason(format!("Failed to set pragmas: {e}")))?; Ok(Self { conn: SendWrapper::new(Some(conn)), @@ -857,6 +872,14 @@ impl NativeDatabase { /// Batches are received as `serde_json::Value` and deserialized via serde so /// that `null` visibility values map to `None` instead of crashing napi's /// `Option` object conversion (#709). + /// + /// `file_hashes` is committed in its own transaction, separate from node + /// insertion (#1731) — callers that need edge-consistent hashes (i.e. the + /// standard incremental build pipeline) should pass an empty array here + /// and commit hashes themselves once resolveImports/buildEdges have + /// finished rebuilding the affected files' edges (see + /// `insertNodes.commitFileHashes` on the JS side, or + /// `insert_nodes::commit_file_hashes` for the all-Rust orchestrator). #[napi(ts_args_type = "batches: Array<{ file: string; definitions: Array<{ name: string; kind: string; line: number; endLine?: number; visibility?: string; children: Array<{ name: string; kind: string; line: number; endLine?: number; visibility?: string }> }>; exports: Array<{ name: string; kind: string; line: number }> }>, fileHashes: FileHashEntry[], removedFiles: string[]")] pub fn bulk_insert_nodes( &self, @@ -869,9 +892,13 @@ impl NativeDatabase { napi::Error::from_reason(format!("bulk_insert_nodes: invalid batches: {e}")) })?; let conn = self.conn()?; - Ok(insert_nodes::do_insert_nodes(conn, &batches, &file_hashes, &removed_files) + let insert_ok = insert_nodes::do_insert_nodes(conn, &batches, &removed_files) .inspect_err(|e| eprintln!("[NativeDatabase] bulk_insert_nodes failed: {e}")) - .is_ok()) + .is_ok(); + let hashes_ok = insert_nodes::commit_file_hashes(conn, &file_hashes) + .inspect_err(|e| eprintln!("[NativeDatabase] bulk_insert_nodes hash commit failed: {e}")) + .is_ok(); + Ok(insert_ok && hashes_ok) } /// Bulk-insert edge rows using chunked multi-value INSERT statements. @@ -1437,14 +1464,22 @@ impl NativeDatabase { config_json: String, aliases_json: String, opts_json: String, + // Monorepo workspace packages (JSON array of `WorkspacePackage`), + // detected on the JS side by `detectWorkspaces()` — see + // `resolve::resolve_via_workspace`'s doc comment (issue #1927). + // `Option` for compatibility with older JS callers built against a + // pre-#1927 native binary that never passes this argument. + workspaces_json: Option, ) -> napi::Result { let conn = self.conn()?; + let workspaces_json = workspaces_json.unwrap_or_default(); let result = crate::domain::graph::builder::pipeline::run_pipeline( conn, &root_dir, &config_json, &aliases_json, &opts_json, + &workspaces_json, ) .map_err(|e| napi::Error::from_reason(format!("build_graph failed: {e}")))?; serde_json::to_string(&result) diff --git a/crates/codegraph-core/src/db/repository/graph_read.rs b/crates/codegraph-core/src/db/repository/graph_read.rs index b55bec051..7b7ef2219 100644 --- a/crates/codegraph-core/src/db/repository/graph_read.rs +++ b/crates/codegraph-core/src/db/repository/graph_read.rs @@ -28,6 +28,41 @@ fn escape_like(s: &str) -> String { out } +/// Append an `AND (col LIKE ? [OR col LIKE ? ...])` fragment for a multi-value +/// file filter and push the corresponding escaped LIKE params. Mirrors +/// `buildFileConditionSQL()` / `NodeQuery.fileFilter()` in +/// `src/db/query-builder.ts`, which is why callers must accept `Vec` +/// (repeatable `-f/--file`) rather than a single `String`. No-op when `files` +/// is empty. Advances `*idx` past the placeholders it consumes. +fn push_file_filter( + sql: &mut String, + params_v: &mut Vec>, + idx: &mut usize, + column: &str, + files: &[String], +) { + if files.is_empty() { + return; + } + let start = *idx; + if files.len() == 1 { + sql.push_str(&format!(" AND {column} LIKE ?{start} ESCAPE '\\'")); + params_v.push(Box::new(format!("%{}%", escape_like(&files[0])))); + *idx += 1; + return; + } + let clauses: Vec = files + .iter() + .enumerate() + .map(|(i, _)| format!("{column} LIKE ?{} ESCAPE '\\'", start + i)) + .collect(); + sql.push_str(&format!(" AND ({})", clauses.join(" OR "))); + for f in files { + params_v.push(Box::new(format!("%{}%", escape_like(f)))); + } + *idx += files.len(); +} + /// Check if a file path looks like a test file (mirrors `isTestFile` in JS). fn is_test_file(file: &str) -> bool { file.contains(".test.") @@ -143,7 +178,7 @@ struct FnDepsCallerWithId { fn build_fn_deps_match_query( name: &str, kind: Option<&str>, - file: Option<&str>, + file: Option<&[String]>, ) -> (String, Vec>) { let default_kinds: Vec = vec![ "function".to_string(), @@ -180,8 +215,7 @@ fn build_fn_deps_match_query( idx += kinds.len(); } if let Some(f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - params_v.push(Box::new(format!("%{}%", escape_like(f)))); + push_file_filter(&mut sql, &mut params_v, &mut idx, "n.file", f); } (sql, params_v) @@ -685,7 +719,7 @@ fn validate_triage_role(role: Option<&str>) -> napi::Result<()> { fn build_triage_query( kind: Option<&str>, role: Option<&str>, - file: Option<&str>, + file: Option<&[String]>, no_tests: bool, ) -> (String, Vec>) { let kinds_to_use: Vec<&str> = match kind { @@ -725,9 +759,7 @@ fn build_triage_query( sql.push_str(&format!(" {}", test_filter_clauses("n.file"))); } if let Some(f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); - idx += 1; + push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f); } if let Some(r) = role { if r == "dead" { @@ -999,7 +1031,7 @@ impl NativeDatabase { &self, scope_name: String, kind: Option, - file: Option, + file: Option>, ) -> napi::Result> { let conn = self.conn()?; @@ -1014,8 +1046,7 @@ impl NativeDatabase { idx += 1; } if let Some(ref f) = file { - sql.push_str(&format!(" AND file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); + push_file_filter(&mut sql, &mut param_values, &mut idx, "file", f); } sql.push_str(" ORDER BY file, line"); @@ -1031,53 +1062,35 @@ impl NativeDatabase { .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_scope collect: {e}"))) } - /// Find nodes by qualified name with optional file filter. + /// Find nodes by qualified name with optional (multi-value) file filter. #[napi] pub fn find_node_by_qualified_name( &self, qualified_name: String, - file: Option, + file: Option>, ) -> napi::Result> { let conn = self.conn()?; + let mut sql = "SELECT * FROM nodes WHERE qualified_name = ?1".to_string(); + let mut param_values: Vec> = + vec![Box::new(qualified_name)]; + let mut idx = 2; if let Some(ref f) = file { - let pattern = format!("%{}%", escape_like(f)); - let mut stmt = conn - .prepare_cached( - "SELECT * FROM nodes WHERE qualified_name = ?1 AND file LIKE ?2 ESCAPE '\\' ORDER BY file, line", - ) - .map_err(|e| { - napi::Error::from_reason(format!( - "find_node_by_qualified_name prepare: {e}" - )) - })?; - let rows = stmt - .query_map(params![qualified_name, pattern], read_node_row) - .map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")) - })?; - rows.collect::, _>>().map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) - }) - } else { - let mut stmt = conn - .prepare_cached( - "SELECT * FROM nodes WHERE qualified_name = ?1 ORDER BY file, line", - ) - .map_err(|e| { - napi::Error::from_reason(format!( - "find_node_by_qualified_name prepare: {e}" - )) - })?; - let rows = stmt - .query_map(params![qualified_name], read_node_row) - .map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")) - })?; - rows.collect::, _>>().map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) - }) + push_file_filter(&mut sql, &mut param_values, &mut idx, "file", f); } + sql.push_str(" ORDER BY file, line"); + + let mut stmt = conn.prepare_cached(&sql).map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name prepare: {e}")) + })?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), read_node_row) + .map_err(|e| napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")))?; + rows.collect::, _>>().map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) + }) } /// Find nodes matching a name pattern with fan-in count. @@ -1086,7 +1099,7 @@ impl NativeDatabase { &self, name_pattern: String, kinds: Option>, - file: Option, + file: Option>, ) -> napi::Result> { let conn = self.conn()?; @@ -1112,8 +1125,7 @@ impl NativeDatabase { } } if let Some(ref f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); + push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f); } let mut stmt = conn @@ -1156,7 +1168,7 @@ impl NativeDatabase { &self, kind: Option, role: Option, - file: Option, + file: Option>, no_tests: Option, ) -> napi::Result> { validate_triage_kind(kind.as_deref())?; @@ -1186,7 +1198,7 @@ impl NativeDatabase { #[napi] pub fn list_function_nodes( &self, - file: Option, + file: Option>, pattern: Option, no_tests: Option, ) -> napi::Result> { @@ -1197,7 +1209,7 @@ impl NativeDatabase { #[napi] pub fn iterate_function_nodes( &self, - file: Option, + file: Option>, pattern: Option, no_tests: Option, ) -> napi::Result> { @@ -1642,8 +1654,15 @@ impl NativeDatabase { .map(|k| format!("'{k}'")) .collect::>() .join(","); + // ORDER BY id: without an explicit order, SQLite's row order for a + // bare WHERE scan is unspecified — it happened to track physical/ + // insertion order, which is only deterministic now that the build + // pipeline inserts nodes in a fixed (BTreeMap-sorted) order (#1734). + // Sorting explicitly here removes the dependency on that unspecified + // behavior so downstream consumers (e.g. community detection) build + // the same graph on every run regardless of how rows are stored. let sql = format!( - "SELECT id, name, kind, file FROM nodes WHERE kind IN ({kinds_sql})" + "SELECT id, name, kind, file FROM nodes WHERE kind IN ({kinds_sql}) ORDER BY id" ); let mut stmt = conn .prepare_cached(&sql) @@ -1663,12 +1682,17 @@ impl NativeDatabase { } /// Get all 'calls' edges. + /// + /// Includes `dynamic` alongside `confidence` so consumers (e.g. cycle + /// detection, #1844) can distinguish confirmed static calls from + /// low-confidence dynamic-dispatch guesses. #[napi] pub fn get_call_edges(&self) -> napi::Result> { let conn = self.conn()?; let mut stmt = conn .prepare_cached( - "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls'", + "SELECT source_id, target_id, confidence, dynamic FROM edges WHERE kind = 'calls' \ + ORDER BY source_id, target_id", ) .map_err(|e| napi::Error::from_reason(format!("get_call_edges prepare: {e}")))?; let rows = stmt @@ -1677,6 +1701,7 @@ impl NativeDatabase { source_id: row.get("source_id")?, target_id: row.get("target_id")?, confidence: row.get("confidence")?, + dynamic: row.get("dynamic")?, }) }) .map_err(|e| napi::Error::from_reason(format!("get_call_edges: {e}")))?; @@ -1688,8 +1713,10 @@ impl NativeDatabase { #[napi] pub fn get_file_nodes_all(&self) -> napi::Result> { let conn = self.conn()?; + // ORDER BY id — see the comment in get_callable_nodes for why an + // explicit order matters for build-to-build determinism (#1734). let mut stmt = conn - .prepare_cached("SELECT id, name, file FROM nodes WHERE kind = 'file'") + .prepare_cached("SELECT id, name, file FROM nodes WHERE kind = 'file' ORDER BY id") .map_err(|e| napi::Error::from_reason(format!("get_file_nodes_all prepare: {e}")))?; let rows = stmt .query_map([], |row| { @@ -1710,7 +1737,8 @@ impl NativeDatabase { let conn = self.conn()?; let mut stmt = conn .prepare_cached( - "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type')", + "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type') \ + ORDER BY source_id, target_id", ) .map_err(|e| napi::Error::from_reason(format!("get_import_edges prepare: {e}")))?; let rows = stmt @@ -1849,7 +1877,7 @@ impl NativeDatabase { /// Shared implementation for list_function_nodes / iterate_function_nodes. fn query_function_nodes( &self, - file: Option, + file: Option>, pattern: Option, no_tests: Option, ) -> napi::Result> { @@ -1865,9 +1893,7 @@ impl NativeDatabase { let mut idx = 1; if let Some(ref f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); - idx += 1; + push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f); } if let Some(ref p) = pattern { sql.push_str(&format!(" AND n.name LIKE ?{idx} ESCAPE '\\'")); @@ -2114,7 +2140,7 @@ impl NativeDatabase { name: String, depth: Option, no_tests: Option, - file: Option, + file: Option>, kind: Option, ) -> napi::Result { let conn = self.conn()?; diff --git a/crates/codegraph-core/src/db/repository/read_types.rs b/crates/codegraph-core/src/db/repository/read_types.rs index 73e4e319e..10d8fe5d1 100644 --- a/crates/codegraph-core/src/db/repository/read_types.rs +++ b/crates/codegraph-core/src/db/repository/read_types.rs @@ -145,6 +145,11 @@ pub struct NativeCallEdgeRow { pub source_id: i32, pub target_id: i32, pub confidence: Option, + /// 0 or 1 — flags calls resolved via dynamic dispatch (vs. a direct + /// static reference). Paired with `confidence` lets consumers (e.g. + /// cycle detection, #1844) distinguish confirmed calls from + /// low-confidence dynamic-dispatch guesses. + pub dynamic: u32, } /// File node row — mirrors `FileNodeRow`. diff --git a/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs b/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs index 777c416c1..aa531e38b 100644 --- a/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs +++ b/crates/codegraph-core/src/domain/graph/builder/barrel_resolution.rs @@ -7,11 +7,19 @@ use std::collections::HashSet; +use crate::types::RenamedImport; + /// Minimal view of a single reexport entry, borrowed from the caller's data. pub struct ReexportRef<'a> { pub source: &'a str, pub names: &'a [String], pub wildcard_reexport: bool, + /// `{ local, imported }` pairs for `export { X as Y } from …` specifiers + /// within this entry: `local` is the external name (Y) a consumer of the + /// barrel imports, `imported` is the name (X) actually declared in + /// `source`. Lets `resolve_barrel_export` translate a consumer's + /// requested name back to X before matching it against `names` (#1823). + pub renames: &'a [RenamedImport], } /// Trait that abstracts over the different context types in `edge_builder` and @@ -26,6 +34,31 @@ pub trait BarrelContext { fn has_definition(&self, file_path: &str, symbol: &str) -> bool; } +/// Result of successfully resolving a symbol through a barrel chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarrelResolution { + /// The file that actually defines the symbol. + pub file: String, + /// The name the symbol is declared under in `file`. Identical to the + /// requested symbol name unless one of the barrel hops in the chain + /// renamed it (`export { X as Y } from …`), in which case this is the + /// *original* declared name (X) to search for in `file` (#1823). + pub name: String, +} + +/// Translate a consumer-requested name through a single reexport entry's +/// rename table, if it renamed that name (`export { X as Y } from …` records +/// `{ local: Y, imported: X }`). Returns `symbol_name` unchanged when the +/// entry doesn't rename it — covers both "not renamed at all" and "requested +/// name isn't one of this entry's external aliases" (#1823). +fn translate_through_rename(re: &ReexportRef, symbol_name: &str) -> String { + re.renames + .iter() + .find(|r| r.local == symbol_name) + .map(|r| r.imported.clone()) + .unwrap_or_else(|| symbol_name.to_string()) +} + /// Recursively resolve a symbol through barrel reexport chains. /// /// Mirrors `resolveBarrelExport()` in `resolve-imports.ts`. @@ -36,7 +69,7 @@ pub fn resolve_barrel_export( barrel_path: &str, symbol_name: &str, visited: &mut HashSet, -) -> Option { +) -> Option { if visited.contains(barrel_path) { return None; } @@ -45,27 +78,34 @@ pub fn resolve_barrel_export( let reexports = ctx.reexports_for(barrel_path)?; for re in &reexports { + // Translate the requested external name (Y) back to the name + // actually declared in `re.source` (X) before matching + // `re.names`/checking the target's definitions — `re.names` always + // carries the original declaration name, never the barrel's + // external alias (#1823). + let lookup_name = translate_through_rename(re, symbol_name); + // Named reexports (non-wildcard) if !re.names.is_empty() && !re.wildcard_reexport { - if re.names.iter().any(|n| n == symbol_name) { - if ctx.has_definition(re.source, symbol_name) { - return Some(re.source.to_string()); + if re.names.iter().any(|n| n == &lookup_name) { + if ctx.has_definition(re.source, &lookup_name) { + return Some(BarrelResolution { file: re.source.to_string(), name: lookup_name }); } - let deeper = resolve_barrel_export(ctx, re.source, symbol_name, visited); + let deeper = resolve_barrel_export(ctx, re.source, &lookup_name, visited); if deeper.is_some() { return deeper; } // Fallback: return source even if no definition found - return Some(re.source.to_string()); + return Some(BarrelResolution { file: re.source.to_string(), name: lookup_name }); } continue; } // Wildcard or empty-names reexports - if ctx.has_definition(re.source, symbol_name) { - return Some(re.source.to_string()); + if ctx.has_definition(re.source, &lookup_name) { + return Some(BarrelResolution { file: re.source.to_string(), name: lookup_name }); } - let deeper = resolve_barrel_export(ctx, re.source, symbol_name, visited); + let deeper = resolve_barrel_export(ctx, re.source, &lookup_name, visited); if deeper.is_some() { return deeper; } @@ -80,7 +120,7 @@ mod tests { use std::collections::HashMap; struct TestContext { - reexports: HashMap, bool)>>, + reexports: HashMap, bool, Vec)>>, definitions: HashMap>, } @@ -89,10 +129,11 @@ mod tests { self.reexports.get(barrel_path).map(|entries| { entries .iter() - .map(|(source, names, wildcard)| ReexportRef { + .map(|(source, names, wildcard, renames)| ReexportRef { source: source.as_str(), names: names.as_slice(), wildcard_reexport: *wildcard, + renames: renames.as_slice(), }) .collect() }) @@ -110,7 +151,7 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/index.ts".to_string(), - vec![("src/utils.ts".to_string(), vec!["foo".to_string()], false)], + vec![("src/utils.ts".to_string(), vec!["foo".to_string()], false, vec![])], ); let mut definitions = HashMap::new(); definitions.insert( @@ -121,7 +162,10 @@ mod tests { let ctx = TestContext { reexports, definitions }; let mut visited = HashSet::new(); let result = resolve_barrel_export(&ctx, "src/index.ts", "foo", &mut visited); - assert_eq!(result.as_deref(), Some("src/utils.ts")); + assert_eq!( + result, + Some(BarrelResolution { file: "src/utils.ts".to_string(), name: "foo".to_string() }) + ); } #[test] @@ -129,7 +173,7 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/index.ts".to_string(), - vec![("src/utils.ts".to_string(), vec![], true)], + vec![("src/utils.ts".to_string(), vec![], true, vec![])], ); let mut definitions = HashMap::new(); definitions.insert( @@ -140,7 +184,10 @@ mod tests { let ctx = TestContext { reexports, definitions }; let mut visited = HashSet::new(); let result = resolve_barrel_export(&ctx, "src/index.ts", "bar", &mut visited); - assert_eq!(result.as_deref(), Some("src/utils.ts")); + assert_eq!( + result, + Some(BarrelResolution { file: "src/utils.ts".to_string(), name: "bar".to_string() }) + ); } #[test] @@ -148,11 +195,11 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/index.ts".to_string(), - vec![("src/mid.ts".to_string(), vec![], true)], + vec![("src/mid.ts".to_string(), vec![], true, vec![])], ); reexports.insert( "src/mid.ts".to_string(), - vec![("src/deep.ts".to_string(), vec!["baz".to_string()], false)], + vec![("src/deep.ts".to_string(), vec!["baz".to_string()], false, vec![])], ); let mut definitions = HashMap::new(); definitions.insert( @@ -163,7 +210,10 @@ mod tests { let ctx = TestContext { reexports, definitions }; let mut visited = HashSet::new(); let result = resolve_barrel_export(&ctx, "src/index.ts", "baz", &mut visited); - assert_eq!(result.as_deref(), Some("src/deep.ts")); + assert_eq!( + result, + Some(BarrelResolution { file: "src/deep.ts".to_string(), name: "baz".to_string() }) + ); } #[test] @@ -171,11 +221,11 @@ mod tests { let mut reexports = HashMap::new(); reexports.insert( "src/a.ts".to_string(), - vec![("src/b.ts".to_string(), vec![], true)], + vec![("src/b.ts".to_string(), vec![], true, vec![])], ); reexports.insert( "src/b.ts".to_string(), - vec![("src/a.ts".to_string(), vec![], true)], + vec![("src/a.ts".to_string(), vec![], true, vec![])], ); let ctx = TestContext { @@ -186,4 +236,41 @@ mod tests { let result = resolve_barrel_export(&ctx, "src/a.ts", "missing", &mut visited); assert_eq!(result, None); } + + /// `export { realName as friendlyName } from './underlying'` — a consumer + /// requesting `friendlyName` must resolve to `underlying.ts`'s `realName` + /// definition, with the resolution's `name` reporting the *declared* name + /// (#1823). + #[test] + fn resolves_renamed_reexport() { + let mut reexports = HashMap::new(); + reexports.insert( + "src/barrel.ts".to_string(), + vec![( + "src/underlying.ts".to_string(), + vec!["realName".to_string()], + false, + vec![RenamedImport { + local: "friendlyName".to_string(), + imported: "realName".to_string(), + }], + )], + ); + let mut definitions = HashMap::new(); + definitions.insert( + "src/underlying.ts".to_string(), + HashSet::from(["realName".to_string()]), + ); + + let ctx = TestContext { reexports, definitions }; + let mut visited = HashSet::new(); + let result = resolve_barrel_export(&ctx, "src/barrel.ts", "friendlyName", &mut visited); + assert_eq!( + result, + Some(BarrelResolution { + file: "src/underlying.ts".to_string(), + name: "realName".to_string(), + }) + ); + } } diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 53f05601d..6af4975bc 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -9,12 +9,23 @@ //! 2. Collect files (with gitignore + extension filter) //! 3. Detect changes (tiered: journal/mtime/hash) //! 4. Parse files in parallel (existing `parallel::parse_files_parallel`) -//! 5. Insert nodes (existing `insert_nodes::do_insert_nodes`) +//! 5. Insert nodes (existing `insert_nodes::do_insert_nodes`) — file_hashes +//! for changed files is NOT written here; see step 7 //! 6. Resolve imports (existing `resolve::resolve_imports_batch`) //! 6b. Re-parse barrel candidates (incremental only) -//! 7. Build import edges + call edges + barrel resolution +//! 7. Build import edges + call edges + barrel resolution, then commit +//! file_hashes for changed files (`insert_nodes::commit_file_hashes`) now +//! that their edges match this revision (#1731) //! 8. Structure metrics + role classification //! 9. Finalize (metadata, journal) +//! +//! Steps 5 and 7 propagate write failures via `?` instead of discarding +//! them: a transaction that never started (or never committed) for nodes or +//! edges now aborts `run_pipeline` with `Err`, which `NativeDatabase::build_graph` +//! turns into a thrown napi error. The JS caller (`tryNativeOrchestrator`) +//! already catches that and falls back to the JS/WASM pipeline, so a write +//! failure now triggers a real retry instead of a "successful" build over an +//! incomplete graph (#1827). use crate::domain::graph::builder::stages::detect_changes; use crate::infrastructure::config::{BuildConfig, BuildOpts, BuildPathAliases}; @@ -30,7 +41,7 @@ use crate::features::structure; use crate::types::{FileSymbols, ImportResolutionInput, TypeMapEntry}; use rusqlite::Connection; use serde::Serialize; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::time::Instant; @@ -105,6 +116,12 @@ struct PipelineSetup { include_dataflow: bool, include_ast: bool, force_full_rebuild: bool, + /// Monorepo workspace packages, keyed by package name. Detected by the JS + /// caller (`detectWorkspaces()` in infrastructure/config.ts — no Rust + /// equivalent; see `resolve::resolve_via_workspace`'s doc comment) and + /// serialized alongside aliases/opts. Empty when the project has no + /// workspace config (issue #1927). + workspaces: HashMap, } fn pipeline_setup( @@ -112,6 +129,7 @@ fn pipeline_setup( config_json: &str, aliases_json: &str, opts_json: &str, + workspaces_json: &str, ) -> Result { let config: BuildConfig = serde_json::from_str(config_json).map_err(|e| format!("config parse error: {e}"))?; @@ -119,12 +137,22 @@ fn pipeline_setup( serde_json::from_str(aliases_json).map_err(|e| format!("aliases parse error: {e}"))?; let opts: BuildOpts = serde_json::from_str(opts_json).map_err(|e| format!("opts parse error: {e}"))?; + let workspace_packages: Vec = if workspaces_json.trim().is_empty() { + Vec::new() + } else { + serde_json::from_str(workspaces_json).map_err(|e| format!("workspaces parse error: {e}"))? + }; let napi_aliases = aliases.to_napi_aliases(); let incremental = opts.incremental.unwrap_or(config.build.incremental); let include_dataflow = opts.dataflow.unwrap_or(true); let include_ast = opts.ast.unwrap_or(true); let force_full_rebuild = check_version_mismatch(conn); + let workspaces = resolve::workspaces_from_packages(&workspace_packages); + // Reset once per build, mirroring `setWorkspaces()`'s + // `_workspaceResolvedPaths.clear()` in resolve.ts — must happen before + // Stage 6/6b resolve any imports below. + resolve::reset_workspace_resolved_paths(); Ok(PipelineSetup { config, @@ -134,6 +162,7 @@ fn pipeline_setup( include_dataflow, include_ast, force_full_rebuild, + workspaces, }) } @@ -164,30 +193,50 @@ fn early_exit_result( } } +/// `(saved_reverse_dep_edges, saved_sibling_groups, removal_reverse_deps, +/// removed_file_neighbors)` — see `save_and_purge_changed`. +type SaveAndPurgeResult = ( + Vec, + HashMap>, + Vec, + Vec, +); + /// Save reverse-dep edges (and reverse-deps of removed files) before purging /// changed files. Mirrors the JS save-then-purge sequence in `build-edges.ts` -/// (#1012). Returns `(saved_reverse_dep_edges, removal_reverse_deps)` so the -/// pipeline can reconnect them after Stage 5 and reclassify roles in Stage 8. +/// (#1012). Returns `(saved_reverse_dep_edges, saved_sibling_groups, +/// removal_reverse_deps, removed_file_neighbors)` so the pipeline can +/// reconnect edges after Stage 5, reclassify roles in Stage 8, and (#1839) +/// fold a removed file's cross-directory neighbors into Stage 8's +/// directory-metrics refresh. fn save_and_purge_changed( conn: &Connection, parse_changes: &[&detect_changes::ChangedFile], change_result: &detect_changes::ChangeResult, opts: &BuildOpts, root_dir: &str, -) -> (Vec, Vec) { +) -> SaveAndPurgeResult { let mut saved_reverse_dep_edges: Vec = Vec::new(); + let mut saved_sibling_groups: HashMap> = + HashMap::new(); let mut removal_reverse_deps: Vec = Vec::new(); if change_result.is_full_build { let has_embeddings = detect_changes::has_embeddings(conn); detect_changes::clear_all_graph_data(conn, has_embeddings); - return (saved_reverse_dep_edges, removal_reverse_deps); + return ( + saved_reverse_dep_edges, + saved_sibling_groups, + removal_reverse_deps, + Vec::new(), + ); } let changed_paths: Vec = parse_changes.iter().map(|c| c.rel_path.clone()).collect(); if !opts.no_reverse_deps.unwrap_or(false) { - saved_reverse_dep_edges = detect_changes::save_reverse_dep_edges(conn, &changed_paths); + (saved_reverse_dep_edges, saved_sibling_groups) = + detect_changes::save_reverse_dep_edges(conn, &changed_paths); if !change_result.removed.is_empty() { let removed_set: HashSet = change_result.removed.iter().cloned().collect(); @@ -198,6 +247,12 @@ fn save_and_purge_changed( } } + // Capture removed files' cross-directory neighbor set BEFORE purging — + // both directions of their import edges are deleted below, so this is + // the last point they can still be discovered from live evidence (#1839). + let removed_file_neighbors = + detect_changes::capture_removed_file_neighbors(conn, &change_result.removed); + let files_to_purge: Vec = change_result .removed .iter() @@ -206,7 +261,12 @@ fn save_and_purge_changed( .collect(); detect_changes::purge_changed_files(conn, &files_to_purge, &[]); - (saved_reverse_dep_edges, removal_reverse_deps) + ( + saved_reverse_dep_edges, + saved_sibling_groups, + removal_reverse_deps, + removed_file_neighbors, + ) } /// Parse a changed-file slice in parallel and key the results by relative path. @@ -215,12 +275,12 @@ fn parse_and_index_files( root_dir: &str, include_dataflow: bool, include_ast: bool, -) -> HashMap { +) -> BTreeMap { let files_to_parse: Vec = parse_changes.iter().map(|c| c.abs_path.clone()).collect(); let parsed = parallel::parse_files_parallel(&files_to_parse, root_dir, include_dataflow, include_ast); - let mut file_symbols: HashMap = HashMap::new(); + let mut file_symbols: BTreeMap = BTreeMap::new(); for mut sym in parsed { let rel = relative_path(root_dir, &sym.file); sym.file = rel.clone(); @@ -232,10 +292,11 @@ fn parse_and_index_files( /// Build the batched import-resolution input set and run resolution, returning /// `(batch_resolved, known_files)`. Mirrors stage 6 of `run_pipeline`. fn resolve_pipeline_imports( - file_symbols: &HashMap, + file_symbols: &BTreeMap, collect_files: &[String], root_dir: &str, napi_aliases: &crate::types::PathAliases, + workspaces: &HashMap, ) -> (HashMap, HashSet) { let mut batch_inputs: Vec = Vec::new(); for (rel_path, symbols) in file_symbols { @@ -253,8 +314,13 @@ fn resolve_pipeline_imports( } let known_files: HashSet = collect_files.iter().map(|f| relative_path(root_dir, f)).collect(); - let resolved = - resolve::resolve_imports_batch(&batch_inputs, root_dir, napi_aliases, Some(&known_files)); + let resolved = resolve::resolve_imports_batch( + &batch_inputs, + root_dir, + napi_aliases, + Some(&known_files), + Some(workspaces), + ); let mut batch_resolved: HashMap = HashMap::new(); for r in &resolved { let key = format!("{}|{}", r.from_file, r.import_source); @@ -267,11 +333,18 @@ fn resolve_pipeline_imports( fn reconnect_saved_reverse_dep_edges( conn: &Connection, saved: &[detect_changes::SavedReverseDepEdge], + saved_sibling_groups: &HashMap>, + max_align_group_size: usize, ) { if saved.is_empty() { return; } - let (reconnected, dropped) = detect_changes::reconnect_reverse_dep_edges(conn, saved); + let (reconnected, dropped) = detect_changes::reconnect_reverse_dep_edges( + conn, + saved, + saved_sibling_groups, + max_align_group_size, + ); if dropped > 0 { eprintln!( "[codegraph] reconnect_reverse_dep_edges: {reconnected} reconnected, {dropped} dropped (target nodes not found)" @@ -283,13 +356,23 @@ fn reconnect_saved_reverse_dep_edges( /// structure rebuild based on the same gates as the JS pipeline. The change /// set is read from `file_symbols.keys()` because only truly-changed files /// are present (reverse-deps are reconnected, not re-parsed). +/// +/// `removed_files` is threaded through separately from `parse_changes_len` +/// (which only counts re-parsed files) so the fast path's directory-metrics +/// refresh also covers files deleted from a directory, not just files added +/// or modified within it (#1738). `removed_file_neighbors` — the +/// cross-directory import neighbors of `removed_files`, captured before they +/// were purged — lets that refresh also reach a directory whose only link to +/// the touched set was an edge to/from one of those removed files (#1839). fn run_structure_phase( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, collect_directories: &HashSet, root_dir: &str, line_count_map: &HashMap, parse_changes_len: usize, + removed_files: &[String], + removed_file_neighbors: &[String], is_full_build: bool, ) { let changed_files: Vec = file_symbols.keys().cloned().collect(); @@ -300,6 +383,12 @@ fn run_structure_phase( if use_fast_path { structure::update_changed_file_metrics(conn, &changed_files, line_count_map, file_symbols); + structure::refresh_affected_directory_metrics( + conn, + &changed_files, + removed_files, + removed_file_neighbors, + ); } else { let changed_for_structure: Option> = if is_full_build { None @@ -323,7 +412,7 @@ fn run_structure_phase( /// nodes are gone (#1027). fn run_role_classification( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, removal_reverse_deps: Vec, is_full_build: bool, ) { @@ -364,7 +453,7 @@ struct AnalysisPersistenceResult { /// analysis scope. fn run_analysis_persistence( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_scope: Option<&Vec>, opts: &BuildOpts, include_ast: bool, @@ -428,13 +517,14 @@ pub fn run_pipeline( config_json: &str, aliases_json: &str, opts_json: &str, + workspaces_json: &str, ) -> Result { let total_start = Instant::now(); let mut timing = PipelineTiming::default(); // ── Stage 1: Deserialize config ──────────────────────────────────── let t0 = Instant::now(); - let setup = pipeline_setup(conn, config_json, aliases_json, opts_json)?; + let setup = pipeline_setup(conn, config_json, aliases_json, opts_json, workspaces_json)?; let PipelineSetup { config, napi_aliases, @@ -443,6 +533,7 @@ pub fn run_pipeline( include_dataflow, include_ast, force_full_rebuild, + workspaces, } = setup; timing.setup_ms = t0.elapsed().as_secs_f64() * 1000.0; @@ -493,7 +584,7 @@ pub fn run_pipeline( // Stage 3b: save reverse-dep edges (incremental) or clear all (full), // then purge changed files. Returns the saved edges for Stage 7 // reconnect and the removal reverse-dep set for Stage 8 reclassification. - let (saved_reverse_dep_edges, removal_reverse_deps) = + let (saved_reverse_dep_edges, saved_sibling_groups, removal_reverse_deps, removed_file_neighbors) = save_and_purge_changed(conn, &parse_changes, &change_result, &opts, root_dir); // ── Stage 4: Parse files ─────────────────────────────────────────── @@ -505,31 +596,62 @@ pub fn run_pipeline( timing.parse_ms = t0.elapsed().as_secs_f64() * 1000.0; // ── Stage 5: Insert nodes ────────────────────────────────────────── + // file_hashes for these files is deliberately NOT written here — only + // node/edge-adjacent (contains, parameter_of) data plus removed-file hash + // cleanup. Committing a changed file's hash this early (before Stage 7 + // rebuilds its import/call edges) would let the hash claim "up to date" + // even if edge-building later fails or is interrupted, permanently + // desyncing file_hashes from the edges it's supposed to gate re-parsing + // on (#1731). The hash is committed at the end of Stage 7 instead, once + // edges genuinely match this revision — see `commit_file_hashes` below. + // A failure here propagates via `?` instead of being discarded: nodes + // are the foundation every later stage builds on, so a transaction + // failure must abort the pipeline and surface as a thrown error rather + // than a "successful" build with missing data (#1827). let t0 = Instant::now(); let insert_batches = build_insert_batches(&file_symbols); let file_hashes = build_file_hash_entries(&parse_changes); - let _ = crate::domain::graph::builder::stages::insert_nodes::do_insert_nodes( + crate::domain::graph::builder::stages::insert_nodes::do_insert_nodes( conn, &insert_batches, - &file_hashes, &change_result.removed, - ); + ) + .map_err(|e| format!("insert_nodes failed: {e}"))?; detect_changes::heal_metadata(conn, &change_result.metadata_updates); timing.insert_ms = t0.elapsed().as_secs_f64() * 1000.0; // ── Stage 6: Resolve imports ─────────────────────────────────────── let t0 = Instant::now(); - let (mut batch_resolved, known_files) = - resolve_pipeline_imports(&file_symbols, &collect_result.files, root_dir, &napi_aliases); + let (mut batch_resolved, known_files) = resolve_pipeline_imports( + &file_symbols, + &collect_result.files, + root_dir, + &napi_aliases, + &workspaces, + ); timing.resolve_ms = t0.elapsed().as_secs_f64() * 1000.0; // ── Stage 6b: Re-parse barrel candidates (incremental only) ───────── - if !change_result.is_full_build { + // `barrel_candidates_added` collects only the paths merged into + // `file_symbols` here — i.e. files loaded purely to resolve reexport + // chains, not files that are genuinely part of this build's changed + // set. Only those transient files are eligible for `barrel_only_files` + // classification below (mirrors `resolve-imports.ts::reparseBarrelFiles`, + // which marks barrel-only status inside this same re-parse loop rather + // than recomputing it over the whole `fileSymbols` map — #1848). + let barrel_candidates_added: Vec = if !change_result.is_full_build { reparse_barrel_candidates( - conn, root_dir, &napi_aliases, &known_files, - &mut file_symbols, &mut batch_resolved, - ); - } + conn, + root_dir, + &napi_aliases, + &known_files, + &workspaces, + &mut file_symbols, + &mut batch_resolved, + ) + } else { + Vec::new() + }; // ── Stage 7: Build edges ─────────────────────────────────────────── let t0 = Instant::now(); @@ -543,15 +665,27 @@ pub fn run_pipeline( root_dir: root_dir.to_string(), aliases: napi_aliases.clone(), known_files, + workspaces: workspaces.clone(), }; - // Build reexport map and detect barrel files + // Build reexport map and detect barrel files. Classification is scoped to + // `barrel_candidates_added` (empty on full builds) rather than every key + // in `file_symbols` — a file that's genuinely part of this build's + // changed set must always get its own non-reexport imports emitted, + // regardless of whether it happens to satisfy the reexports>=ownDefs + // heuristic (#1848). import_ctx.reexport_map = import_edges::build_reexport_map(&import_ctx); - import_ctx.barrel_only_files = import_edges::detect_barrel_only_files(&import_ctx); - - // Build import edges + import_ctx.barrel_only_files = + import_edges::detect_barrel_only_files(&import_ctx, &barrel_candidates_added); + + // Build import edges. A write failure here (transaction-start, a + // malformed chunk, or commit) propagates via `?` instead of being + // discarded — the old `run_pipeline` had no way to know edges were + // never written for some or all files, so it returned `Ok(...)` (a + // "successful" build) over an incomplete edge set (#1827). let import_edge_rows = import_edges::build_import_edges(conn, &import_ctx); - import_edges::insert_edges(conn, &import_edge_rows); + import_edges::insert_edges(conn, &import_edge_rows) + .map_err(|e| format!("import edge insertion failed: {e}"))?; // Phase 8.2: cross-file return-type propagation — seed each file's // type_map with the return types of imported functions before call-edge @@ -561,9 +695,39 @@ pub fn run_pipeline( // Build call edges using existing Rust edge_builder (internal path) // For now, call edges are built via the existing napi-exported function's // internal logic. We load nodes from DB and pass to the edge builder. - build_and_insert_call_edges(conn, &file_symbols, &import_ctx, !change_result.is_full_build); + // Same error-propagation rationale as import edges above (#1827) — this + // call used to run unchecked, with its `Result` never captured. + build_and_insert_call_edges( + conn, + &file_symbols, + &import_ctx, + !change_result.is_full_build, + config.analysis.points_to_max_iterations, + ) + .map_err(|e| format!("call edge insertion failed: {e}"))?; - reconnect_saved_reverse_dep_edges(conn, &saved_reverse_dep_edges); + reconnect_saved_reverse_dep_edges( + conn, + &saved_reverse_dep_edges, + &saved_sibling_groups, + config.build.reverse_dep_alignment_max_group_size, + ); + + // Now that edges reflect this revision, commit file_hashes for the + // changed files (#1731). Deferred from Stage 5 — see the comment there. + // Only reached once import and call edges above are confirmed written — + // an edge-insertion failure now aborts the pipeline (via `?`) before this + // point instead of committing a hash over an incomplete edge set (#1827). + // A failure of this commit itself stays non-fatal (log and continue): + // it only affects bookkeeping, not correctness — the file's hash simply + // keeps its old value, so the next build re-detects and re-processes it + // (the same self-healing property #1731 relies on). + if let Err(e) = crate::domain::graph::builder::stages::insert_nodes::commit_file_hashes( + conn, + &file_hashes, + ) { + eprintln!("[codegraph] commit_file_hashes failed: {e}"); + } timing.edges_ms = t0.elapsed().as_secs_f64() * 1000.0; // ── Stage 8: Structure + roles ───────────────────────────────────── @@ -584,6 +748,8 @@ pub fn run_pipeline( root_dir, &line_count_map, parse_changes.len(), + &change_result.removed, + &removed_file_neighbors, change_result.is_full_build, ); timing.structure_ms = t0.elapsed().as_secs_f64() * 1000.0; @@ -741,14 +907,24 @@ fn collect_source_files( /// barrel-through edges are silently dropped on every incremental rebuild /// (#1174). Convergence is guaranteed because `file_symbols` grows /// monotonically and is bounded by the set of barrel files in the project. +/// +/// Returns the relative paths of every file merged into `file_symbols` by +/// this call (across all iterations) — files loaded solely to resolve +/// reexport chains, as distinct from the genuinely-changed files the caller +/// already had in `file_symbols` before Stage 6b ran. The caller uses this +/// list to scope `barrel_only_files` classification (#1848): a barrel-only +/// skip must never apply to a file that's actually part of this build's +/// changed set, only to these transiently side-loaded ones. fn reparse_barrel_candidates( conn: &Connection, root_dir: &str, napi_aliases: &crate::types::PathAliases, known_files: &HashSet, - file_symbols: &mut HashMap, + workspaces: &HashMap, + file_symbols: &mut BTreeMap, batch_resolved: &mut HashMap, -) { +) -> Vec { + let mut all_added: Vec = Vec::new(); // Find all barrel files from DB (files that have 'reexports' edges) let barrel_files_in_db: HashSet = { let rows: Vec = match conn.prepare( @@ -843,6 +1019,7 @@ fn reparse_barrel_candidates( root_dir, napi_aliases, Some(known_files), + Some(workspaces), ); for r in &resolved_batch { let key = format!("{}|{}", r.from_file, r.import_source); @@ -862,7 +1039,10 @@ fn reparse_barrel_candidates( &barrel_files_in_db, file_symbols, ); + all_added.extend(newly_added); } + + all_added } /// Walk the imports of `from_files` and return absolute paths of any barrel @@ -873,7 +1053,7 @@ fn collect_imported_barrel_candidates( from_files: &[String], batch_resolved: &HashMap, barrel_files_in_db: &HashSet, - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> Vec { let mut out = Vec::new(); for rel_path in from_files { @@ -907,7 +1087,7 @@ fn collect_reexport_from_barrels( conn: &Connection, root_dir: &str, changed_files: &[String], - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> Vec { let mut out = Vec::new(); let mut stmt = match conn.prepare( @@ -999,7 +1179,7 @@ fn check_version_mismatch(conn: &Connection) -> bool { /// Build InsertNodesBatch from parsed file symbols. fn build_insert_batches( - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> Vec { file_symbols .iter() @@ -1136,7 +1316,7 @@ const EDGE_NODE_KIND_FILTER: &str = "kind IN ('function','method','class','inter /// ultimate definition files barrel chains resolve to. Mirrors the JS /// `relevantFiles` accumulation in `loadNodes` (#976, greptile P1). fn compute_edge_relevant_files( - file_symbols: &HashMap, + file_symbols: &BTreeMap, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, ) -> HashSet { let mut relevant_files: HashSet = file_symbols.keys().cloned().collect(); @@ -1156,7 +1336,7 @@ fn compute_edge_relevant_files( if let Some(ultimate) = import_ctx.resolve_barrel_export(&resolved, clean_name, &mut visited) { - relevant_files.insert(ultimate); + relevant_files.insert(ultimate.file); } } } @@ -1173,7 +1353,7 @@ fn compute_edge_relevant_files( /// `Vec` suitable for the native edge builder. fn load_edge_node_set( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, is_incremental: bool, ) -> Vec { @@ -1277,17 +1457,22 @@ fn load_file_node_id_map(conn: &Connection) -> HashMap { /// Resolve a file's imports to the list of `ImportedName` entries the edge /// builder consumes. Walks barrel chains to the ultimate definition file so /// the edge builder's name-lookup can find the right target (#976 P1). +/// +/// For renamed specifiers (`import { X as Y }`), `ImportedName.imported` +/// carries the original name (X) so `resolve_call_targets` can look it up in +/// the target file instead of the local alias (Y), which only exists in the +/// importing file (#1730). fn collect_imported_names_for_file( abs_str: &str, symbols: &FileSymbols, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, ) -> Vec { use crate::domain::graph::builder::stages::build_edges::ImportedName; + use crate::domain::graph::builder::stages::import_edges::import_name_pairs; let mut imported_names: Vec = Vec::new(); for imp in &symbols.imports { let resolved_path = import_ctx.get_resolved(abs_str, &imp.source); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name).to_string(); + for (local, original, _type_only) in import_name_pairs(imp) { // CJS require bindings are included in imported_names so the receiver-edge // resolver treats them as import artifacts (not locally-defined symbols). // We use an empty target_file so the import-aware call-target lookup @@ -1295,20 +1480,27 @@ fn collect_imported_names_for_file( // through to the same-file shadow node — matching WASM call-resolution // behaviour where CJS bindings are not in importedNamesMap (#1678). if imp.cjs_require.unwrap_or(false) { - imported_names.push(ImportedName { name: clean_name, file: String::new() }); + imported_names.push(ImportedName { + name: local, + file: String::new(), + imported: None, + }); continue; } let mut target_file = resolved_path.clone(); + let mut target_name = original; if import_ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - import_ctx.resolve_barrel_export(&resolved_path, &clean_name, &mut visited) + if let Some(resolved) = + import_ctx.resolve_barrel_export(&resolved_path, &target_name, &mut visited) { - target_file = actual; + target_file = resolved.file; + target_name = resolved.name; } } imported_names.push(ImportedName { - name: clean_name, + imported: if target_name != local { Some(target_name) } else { None }, + name: local, file: target_file, }); } @@ -1325,7 +1517,7 @@ fn collect_imported_names_for_file( /// so method calls and receiver edges on that variable resolve. Must run /// before `build_and_insert_call_edges`. fn propagate_return_types_across_files( - file_symbols: &mut HashMap, + file_symbols: &mut BTreeMap, import_ctx: &ImportEdgeContext, ) { use crate::domain::graph::builder::stages::build_edges::PROPAGATION_HOP_PENALTY; @@ -1357,7 +1549,7 @@ fn propagate_return_types_across_files( /// - `global_return_types`: flat map for qualified `Type.method` lookups; higher /// confidence wins, tie-break is deterministic (paths visited in sorted order). fn build_return_type_index( - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> ( HashMap>, HashMap, @@ -1411,8 +1603,14 @@ fn inject_return_types_for_file( let imported_names = collect_imported_names_for_file(abs_str, symbols, import_ctx); // Later entries overwrite earlier ones on duplicate names — same as the // HashMap collect in build_call_edges. - let imported_map: HashMap = - imported_names.into_iter().map(|e| (e.name, e.file)).collect(); + let mut imported_map: HashMap = HashMap::new(); + let mut imported_original_map: HashMap = HashMap::new(); + for e in imported_names { + if let Some(original) = e.imported { + imported_original_map.insert(e.name.clone(), original); + } + imported_map.insert(e.name, e.file); + } let mut injections: Vec = Vec::new(); let mut injected: HashSet = HashSet::new(); @@ -1429,7 +1627,13 @@ fn inject_return_types_for_file( global_return_types.get(&format!("{receiver}.{}", ca.callee_name)) } None => imported_map.get(&ca.callee_name).and_then(|from| { - return_type_index.get(from).and_then(|m| m.get(&ca.callee_name)) + // The return-type index for the imported file is keyed by the + // function's own declared name — use the original (pre-rename) + // name when the callee is a renamed import binding (#1730). + let callee_original_name = imported_original_map + .get(&ca.callee_name) + .unwrap_or(&ca.callee_name); + return_type_index.get(from).and_then(|m| m.get(callee_original_name)) }), }; @@ -1448,10 +1652,18 @@ fn inject_return_types_for_file( symbols.type_map.extend(injections); } -/// Insert the edges produced by the native edge builder into the edges table. -fn insert_call_edge_rows(conn: &Connection, edges: &[crate::domain::graph::builder::stages::build_edges::ComputedEdge]) { +/// Insert the edges produced by the native edge builder into the edges +/// table. Propagates `do_insert_edges`'s `Result` instead of discarding it +/// (#1827) — `do_insert_edges` already fails fast (transaction-start, +/// bind/execute, or commit) via `?`, but the previous `let _ = …` here threw +/// that signal away, so `run_pipeline` had no way to detect a transaction +/// that never started, or failed to commit, for this file's call edges. +fn insert_call_edge_rows( + conn: &Connection, + edges: &[crate::domain::graph::builder::stages::build_edges::ComputedEdge], +) -> Result<(), String> { if edges.is_empty() { - return; + return Ok(()); } let edge_rows: Vec = edges .iter() @@ -1464,21 +1676,26 @@ fn insert_call_edge_rows(conn: &Connection, edges: &[crate::domain::graph::build dynamic_kind: e.dynamic_kind.clone(), }) .collect(); - let _ = crate::db::repository::edges::do_insert_edges(conn, &edge_rows); + crate::db::repository::edges::do_insert_edges(conn, &edge_rows) + .map_err(|e| format!("call edge insertion failed: {e}")) } /// Full builds always load every node — there is no smaller set anyway. +/// +/// `max_iterations` caps the Phase 8.3 points-to solver's fixed-point loop — +/// forwarded from `config.analysis.points_to_max_iterations` (issue #1753). fn build_and_insert_call_edges( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, import_ctx: &ImportEdgeContext, is_incremental: bool, -) { + max_iterations: u32, +) -> Result<(), String> { use crate::domain::graph::builder::stages::build_edges::*; let all_nodes = load_edge_node_set(conn, file_symbols, import_ctx, is_incremental); if all_nodes.is_empty() { - return; + return Ok(()); } let builtin_receivers = builtin_call_receivers(); @@ -1570,8 +1787,8 @@ fn build_and_insert_call_edges( }); } - let computed_edges = build_call_edges(file_entries, all_nodes, builtin_receivers); - insert_call_edge_rows(conn, &computed_edges); + let computed_edges = build_call_edges(file_entries, all_nodes, builtin_receivers, max_iterations); + insert_call_edge_rows(conn, &computed_edges) } // ── Analysis persistence helpers ───────────────────────────────────────── @@ -1628,7 +1845,7 @@ fn build_analysis_node_map( /// Convert FileSymbols AST nodes to FileAstBatch format for `ast::do_insert_ast_nodes`. fn build_ast_batches( - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, ) -> Vec { let mut batches = Vec::new(); @@ -1657,7 +1874,7 @@ fn build_ast_batches( /// Write complexity metrics from parsed definitions to the `function_complexity` table. fn write_complexity( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, node_id_map: &HashMap<(String, String, u32), i64>, ) -> bool { @@ -1736,7 +1953,7 @@ fn write_complexity( /// Write CFG blocks and edges from parsed definitions to DB tables. fn write_cfg( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, node_id_map: &HashMap<(String, String, u32), i64>, ) -> bool { @@ -1841,7 +2058,7 @@ fn write_def_cfg( /// `makeNodeResolver` logic (prefer same-file match, fall back to global). fn write_dataflow( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, ) -> bool { let tx = match conn.unchecked_transaction() { @@ -2009,7 +2226,7 @@ mod tests { use super::*; use crate::types::{Import, PathAliases}; - fn make_import_ctx(file_symbols: &HashMap) -> ImportEdgeContext { + fn make_import_ctx(file_symbols: &BTreeMap) -> ImportEdgeContext { let mut batch_resolved = HashMap::new(); batch_resolved.insert("/repo/driver.js|./service.js".to_string(), "service.js".to_string()); ImportEdgeContext { @@ -2020,6 +2237,7 @@ mod tests { root_dir: "/repo".to_string(), aliases: PathAliases { base_url: None, paths: vec![] }, known_files: HashSet::new(), + workspaces: HashMap::new(), } } @@ -2048,7 +2266,7 @@ mod tests { receiver_type_name: None, }); - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); file_symbols.insert("service.js".to_string(), service); file_symbols.insert("driver.js".to_string(), driver); let import_ctx = make_import_ctx(&file_symbols); @@ -2079,7 +2297,7 @@ mod tests { receiver_type_name: Some("Factory".to_string()), }); - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); file_symbols.insert("factory.js".to_string(), factory); file_symbols.insert("driver.js".to_string(), driver); let import_ctx = make_import_ctx(&file_symbols); @@ -2110,7 +2328,7 @@ mod tests { receiver_type_name: None, }); - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); file_symbols.insert("service.js".to_string(), service); file_symbols.insert("driver.js".to_string(), driver); let import_ctx = make_import_ctx(&file_symbols); diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index d86c42872..ac76aeba3 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -3,10 +3,11 @@ use std::collections::{HashMap, HashSet}; use napi_derive::napi; use crate::domain::graph::builder::barrel_resolution::{self, BarrelContext, ReexportRef}; +use crate::domain::graph::builder::stages::import_edges::{import_name_pairs, ImportNameSource}; use crate::domain::graph::resolve; use crate::types::{ ArrayCallbackBinding, ArrayElemBinding, FnRefBinding, ForOfBinding, ObjectPropBinding, - ObjectRestParamBinding, ParamBinding, SpreadArgBinding, ThisCallBinding, + ObjectRestParamBinding, ParamBinding, RenamedImport, SpreadArgBinding, ThisCallBinding, }; /// Kind sets for hierarchy edge resolution -- mirrors the JS constants in @@ -46,6 +47,12 @@ pub struct CallInfo { pub struct ImportedName { pub name: String, pub file: String, + /// For renamed specifiers (`import { X as Y }`): the original name + /// exported by `file` (X), when it differs from `name` (the local + /// binding Y). `resolve_call_targets` looks this up in `file` instead of + /// `name` — the renamed local alias only exists in the importing file, + /// not in `file` itself (#1730). + pub imported: Option, } #[napi(object)] @@ -146,10 +153,18 @@ struct EdgeContext<'a> { nodes_by_file: HashMap<&'a str, Vec<&'a NodeInfo>>, builtin_set: HashSet<&'a str>, receiver_kinds: HashSet<&'a str>, + /// Property/method names ever invoked via member-call syntax + /// (`x.name(...)`) across every file in this build pass — see + /// `collect_invoked_property_names` for the #1895 liveness rationale. + invoked_property_names: HashSet<&'a str>, } impl<'a> EdgeContext<'a> { - fn new(all_nodes: &'a [NodeInfo], builtin_receivers: &'a [String]) -> Self { + fn new( + all_nodes: &'a [NodeInfo], + builtin_receivers: &'a [String], + files: &'a [FileEdgeInput], + ) -> Self { let mut nodes_by_name: HashMap<&str, Vec<&NodeInfo>> = HashMap::new(); let mut nodes_by_name_and_file: HashMap<(&str, &str), Vec<&NodeInfo>> = HashMap::new(); let mut nodes_by_file: HashMap<&str, Vec<&NodeInfo>> = HashMap::new(); @@ -170,17 +185,56 @@ impl<'a> EdgeContext<'a> { nodes_by_file, builtin_set, receiver_kinds, + invoked_property_names: collect_invoked_property_names(files), + } + } +} + +/// Collect the set of property/method names ever invoked via member-call +/// syntax (`x.name(...)`) across every file currently being processed — +/// regardless of whether the receiver `x` itself resolves to anything. +/// +/// Used as the "one hop further" liveness check for object-literal-property +/// value-refs (#1895): a function referenced as `{ resolve: someFn }` should +/// only be credited with a `calls` edge from that reference when something, +/// somewhere, actually invokes a `.resolve(...)`-shaped call — otherwise the +/// property is wired up but never read, and `someFn` is genuinely dead. +/// +/// Scope matches whatever `files` the caller passes to `build_call_edges`: +/// the full codebase for a full build, or just the changed file(s) on an +/// incremental one. The incremental case is a narrower, same-build-pass view +/// (a cross-file consumer added in an untouched file won't be seen until the +/// next full rebuild) — the same scoping trade-off already accepted +/// elsewhere in this codebase's incremental classification +/// (`has_active_file_siblings`, exported-via-reexport, and median fan-in/out +/// all recompute from the affected file set only, not the whole graph, in +/// `graph/classifiers/roles.rs`'s incremental path). Mirrors +/// `collectInvokedPropertyNames` in `src/domain/graph/builder/call-resolver.ts`. +fn collect_invoked_property_names(files: &[FileEdgeInput]) -> HashSet<&str> { + let mut names = HashSet::new(); + for file in files { + for call in &file.calls { + if call.receiver.is_some() { + names.insert(call.name.as_str()); + } } } + names } // ── Phase 8.3: points-to analysis ───────────────────────────────────────── -/// Maximum fixed-point iterations for the pts solver. -/// Mirrors `MAX_SOLVER_ITERATIONS` in `src/domain/graph/resolver/points-to.ts`. -/// TODO: wire through `CodegraphConfig.analysis.pointsToMaxIterations` once -/// config plumbing is in place (same pattern as `typePropagationDepth`). -const MAX_SOLVER_ITERATIONS: usize = 50; +/// Default maximum fixed-point iterations for the pts solver — mirrors +/// `MAX_SOLVER_ITERATIONS` in `src/domain/graph/resolver/points-to.ts` and +/// `DEFAULTS.analysis.pointsToMaxIterations` in `src/infrastructure/config.ts`. +/// `build_call_edges()` now receives the resolved value as an explicit +/// `max_iterations` parameter (threaded from `BuildConfig.analysis.points_to_max_iterations` +/// on the native-first pipeline path, or from `ctx.config.analysis.pointsToMaxIterations` +/// via the napi call on the JS-orchestrated per-stage path); production code no +/// longer references this constant directly, so it is `#[cfg(test)]`-gated — +/// it remains only as the fallback default used directly by unit tests below. +#[cfg(test)] +const MAX_SOLVER_ITERATIONS: u32 = 50; /// Per-file points-to binding inputs, borrowed from a `FileEdgeInput`. /// `fn_ref_bindings` must already include the `fn::this → ctx` conversions @@ -202,11 +256,16 @@ struct PtsBindings<'a> { /// Seeds every locally-defined callable and every imported name as pointing /// to itself, generates inclusion constraints (`pts(lhs) ⊇ pts(rhsKey)`) /// from every binding kind, then solves by fixed-point iteration. +/// +/// `max_iterations` caps the fixed-point loop below — resolved from +/// `CodegraphConfig.analysis.pointsToMaxIterations` by the caller (mirrors +/// the `maxIterations` parameter of the TS `buildPointsToMap`). fn build_points_to_map( bindings: &PtsBindings, def_names: &HashSet<&str>, imported_names: &HashMap<&str, &str>, definition_params: &HashMap<&str, Vec<&str>>, + max_iterations: u32, ) -> HashMap> { let mut pts: HashMap> = HashMap::new(); for name in def_names { @@ -340,7 +399,7 @@ fn build_points_to_map( } // Fixed-point iteration: propagate pts sets until no new information flows. - for _ in 0..MAX_SOLVER_ITERATIONS { + for _ in 0..max_iterations { let mut changed = false; for (lhs, rhs_key) in &constraints { let rhs_pts: Option> = pts.get(rhs_key.as_str()) @@ -384,6 +443,7 @@ struct PtsAliasCtx<'a> { is_dynamic: u32, rel_path: &'a str, imported_names: &'a HashMap<&'a str, &'a str>, + imported_original_names: &'a HashMap<&'a str, &'a str>, type_map: &'a HashMap<&'a str, (&'a str, f64)>, } @@ -409,6 +469,7 @@ fn emit_pts_alias_edges<'a>( }; let mut alias_targets = resolve_call_targets( ctx, &alias_call, alias_ctx.rel_path, alias_imported_from, alias_ctx.type_map, alias_ctx.caller_name, + alias_ctx.imported_original_names, ); sort_targets_by_confidence(&mut alias_targets, alias_ctx.rel_path, alias_imported_from); for t in &alias_targets { @@ -436,17 +497,22 @@ fn emit_pts_alias_edges<'a>( /// /// Mirrors the algorithm in builder.js `buildEdges` transaction (call edges /// portion). Import edges are handled separately in JS. +/// +/// `max_iterations` caps the Phase 8.3 points-to solver's fixed-point loop — +/// callers pass `ctx.config.analysis.pointsToMaxIterations` (resolved from +/// `.codegraphrc.json`, defaulting to `DEFAULTS.analysis.pointsToMaxIterations`). #[napi] pub fn build_call_edges( files: Vec, all_nodes: Vec, builtin_receivers: Vec, + max_iterations: u32, ) -> Vec { - let ctx = EdgeContext::new(&all_nodes, &builtin_receivers); + let ctx = EdgeContext::new(&all_nodes, &builtin_receivers, &files); let mut edges = Vec::new(); for file_input in &files { - process_file(&ctx, file_input, &all_nodes, &mut edges); + process_file(&ctx, file_input, &all_nodes, &mut edges, max_iterations); } edges @@ -458,6 +524,11 @@ struct FileContext<'a> { rel_path: &'a str, file_node_id: u32, imported_names: HashMap<&'a str, &'a str>, + /// Local import alias -> original exported name, for renamed specifiers + /// (`import { X as Y }`) only — entries where local === original are + /// omitted. Consulted by `resolve_call_targets` so a call to the local + /// alias resolves against the correct exported symbol (#1730). + imported_original_names: HashMap<&'a str, &'a str>, type_map: HashMap<&'a str, (&'a str, f64)>, defs_with_ids: Vec>, pts_map: Option>>, @@ -498,6 +569,7 @@ fn build_type_map<'a>(file_input: &'a FileEdgeInput) -> HashMap<&'a str, (&'a st fn build_pts_map_for_file( file_input: &FileEdgeInput, imported_names: &HashMap<&str, &str>, + max_iterations: u32, ) -> Option>> { let raw_fn_ref: &[FnRefBinding] = file_input.fn_ref_bindings.as_deref().unwrap_or(&[]); let this_calls: &[ThisCallBinding] = file_input.this_call_bindings.as_deref().unwrap_or(&[]); @@ -554,19 +626,30 @@ fn build_pts_map_for_file( PtsBindings { fn_ref_bindings: &merged_fn_ref, ..bindings } }; - Some(build_points_to_map(&final_bindings, &def_names, imported_names, &definition_params)) + Some(build_points_to_map( + &final_bindings, + &def_names, + imported_names, + &definition_params, + max_iterations, + )) } /// Build all per-file lookup structures needed for edge emission. fn build_file_context<'a>( file_input: &'a FileEdgeInput, all_nodes: &'a [NodeInfo], + max_iterations: u32, ) -> FileContext<'a> { let rel_path = file_input.file.as_str(); let imported_names: HashMap<&str, &str> = file_input .imported_names.iter() .map(|im| (im.name.as_str(), im.file.as_str())) .collect(); + let imported_original_names: HashMap<&str, &str> = file_input + .imported_names.iter() + .filter_map(|im| im.imported.as_deref().map(|orig| (im.name.as_str(), orig))) + .collect(); let type_map = build_type_map(file_input); let file_nodes: Vec<&NodeInfo> = all_nodes.iter().filter(|n| n.file == rel_path).collect(); let defs_with_ids: Vec = file_input.definitions.iter().map(|d| { @@ -581,7 +664,7 @@ fn build_file_context<'a>( node_id, } }).collect(); - let pts_map = build_pts_map_for_file(file_input, &imported_names); + let pts_map = build_pts_map_for_file(file_input, &imported_names, max_iterations); let raw_fn_ref: &[FnRefBinding] = file_input.fn_ref_bindings.as_deref().unwrap_or(&[]); // Case (c) flat-key gate set: lhs names from the *raw* fnRefBindings only // (thisCall conversions are scoped keys and never flat-matched). @@ -590,6 +673,7 @@ fn build_file_context<'a>( rel_path, file_node_id: file_input.file_node_id, imported_names, + imported_original_names, type_map, defs_with_ids, pts_map, @@ -655,6 +739,7 @@ fn emit_no_receiver_pts_edges<'a>( is_dynamic, rel_path: fc.rel_path, imported_names: &fc.imported_names, + imported_original_names: &fc.imported_original_names, type_map: &fc.type_map, }, seen_edges, @@ -697,6 +782,7 @@ fn emit_receiver_pts_edges<'a>( is_dynamic, rel_path: fc.rel_path, imported_names: &fc.imported_names, + imported_original_names: &fc.imported_original_names, type_map: &fc.type_map, }, seen_edges, @@ -711,8 +797,9 @@ fn process_file<'a>( file_input: &'a FileEdgeInput, all_nodes: &'a [NodeInfo], edges: &mut Vec, + max_iterations: u32, ) { - let fc = build_file_context(file_input, all_nodes); + let fc = build_file_context(file_input, all_nodes, max_iterations); // Phase 8.3: tracks pts-resolved edges separately from seen_edges so that a // subsequent direct call to the same caller→target pair can upgrade confidence @@ -735,7 +822,36 @@ fn process_file<'a>( let is_dynamic = if call.dynamic.unwrap_or(false) { 1u32 } else { 0u32 }; let imported_from = fc.imported_names.get(call.name.as_str()).copied(); - let mut targets = resolve_call_targets(ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name); + let mut targets = resolve_call_targets( + ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, &fc.imported_original_names, + ); + // #1771/#1784: value-ref references (object-literal property values, + // Lua builtin reassignment, `instanceof ClassName`) resolve against + // function/method/class-kind targets only. A bare identifier in one + // of these positions is as likely to be a plain data reference + // (`{ name: SOME_CONSTANT }`) as a real function/class, so drop any + // other-kind match rather than fabricating a "calls" edge to a + // constant. `class` is included alongside function/method because + // `instanceof`'s right operand is always a class/constructor + // (#1784) — unlike the original #1771 object-literal case, which is + // function/method only. Applied once here (after all + // resolve_call_targets tiers), mirroring the + // `dynamicKind === 'value-ref'` filter in resolveFallbackTargets + // (stages/build-edges.ts). + if call.dynamic_kind.as_deref() == Some("value-ref") { + targets.retain(|t| t.kind == "function" || t.kind == "method" || t.kind == "class"); + // #1895: object-literal-property value-refs (key_expr set — see + // handle_object_literal_pair_value_ref / shorthand handler) + // additionally require independent evidence that the property is + // actually invoked somewhere (`x.key_expr(...)`) — merely being + // wired into an object literal is not liveness. instanceof/Lua + // value-refs never set key_expr, so they are unaffected. + if let Some(key_expr) = call.key_expr.as_deref() { + if !ctx.invoked_property_names.contains(key_expr) { + targets.clear(); + } + } + } sort_targets_by_confidence(&mut targets, fc.rel_path, imported_from); emit_call_edges(&targets, caller_id, is_dynamic, fc.rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges); @@ -772,7 +888,7 @@ fn process_file<'a>( } } - emit_hierarchy_edges(ctx, file_input, fc.rel_path, edges); + emit_hierarchy_edges(ctx, file_input, fc.rel_path, &fc.imported_names, edges); } /// Callable definition kinds — only function/method bodies act as enclosing @@ -871,10 +987,62 @@ fn find_enclosing_caller<'a>(defs: &[DefWithId<'a>], call_line: u32, file_node_i (file_node_id, "") } +/// Step 2 of the scoped (this/self/super or no-receiver) fallback: exact global +/// name lookup. Mirrors `resolveExactGlobalMatch` in +/// `src/domain/graph/resolver/strategy.ts`. +/// +/// A bare/this/self/super call carries no type qualifier, so `nodes_by_name` +/// can return every same-named symbol in the codebase, filtered only by the +/// loose directory-proximity confidence threshold. Returning all of them +/// turns a single real call site into N-1 false `calls` edges (#1863). Only +/// a single highest-confidence candidate is trustworthy — a tie at the top +/// confidence (e.g. several files at the same directory depth from the +/// caller) is genuinely ambiguous and returns nothing, letting the caller +/// fall through to the narrower same-class-sibling fallback. +/// +/// A `this`/`self`/`super` call is additionally restricted to callable kinds +/// (`is_callable_kind`): such a call is logically "invoke a member of the +/// current instance", which a class/interface/struct/etc. declaration can +/// never satisfy, so an unrelated same-named type declaration must never +/// substitute for a real callable target just because no other candidate +/// exists (#1888). A genuinely bare call (no receiver at all) is left +/// unfiltered — at this layer it is indistinguishable from a `new +/// ClassName()` constructor invocation, which legitimately targets a +/// class-kind definition. +fn resolve_exact_global_match<'a>( + ctx: &EdgeContext<'a>, + call_name: &str, + rel_path: &str, + receiver: Option<&str>, +) -> Vec<&'a NodeInfo> { + let scored: Vec<(&'a NodeInfo, f64)> = ctx.nodes_by_name + .get(call_name) + .map(|v| v.iter() + .filter(|&&n| receiver.is_none() || is_callable_kind(&n.kind)) + .map(|&n| (n, resolve::compute_confidence(rel_path, &n.file, None))) + .filter(|&(_, confidence)| confidence >= 0.5) + .collect()) + .unwrap_or_default(); + if scored.is_empty() { return Vec::new(); } + + let best_confidence = scored.iter().map(|&(_, confidence)| confidence).fold(f64::MIN, f64::max); + let best: Vec<&'a NodeInfo> = scored.iter() + .filter(|&&(_, confidence)| confidence == best_confidence) + .map(|&(n, _)| n) + .collect(); + if best.len() == 1 { best } else { Vec::new() } +} + /// Multi-strategy call target resolution: import-aware → same-file → type-aware → scoped. /// `caller_name` is the enclosing function/method name (e.g. `"Shape.describe"`) used to scope /// `this`/`self`/`super` dispatch to the caller's own class before falling back to a broader scan. /// Mirrors `resolveCallTargets` / `resolveByMethodOrGlobal` in call-resolver.ts. +/// +/// Thin wrapper around `resolve_call_targets_core`: additionally attaches +/// constructor-call attribution (#1892) for bare (no-receiver) calls — see +/// `attach_constructor_targets`. Split out because the core resolver has many +/// early-return tiers, so a single post-processing pass at the call site is +/// simpler than threading the augmentation through every tier. fn resolve_call_targets<'a>( ctx: &EdgeContext<'a>, call: &CallInfo, @@ -882,6 +1050,31 @@ fn resolve_call_targets<'a>( imported_from: Option<&str>, type_map: &HashMap<&str, (&str, f64)>, caller_name: &str, + imported_original_names: &HashMap<&str, &str>, +) -> Vec<&'a NodeInfo> { + let targets = resolve_call_targets_core( + ctx, call, rel_path, imported_from, type_map, caller_name, imported_original_names, + ); + if call.receiver.is_some() { + return targets; + } + let class_name = imported_original_names + .get(call.name.as_str()) + .copied() + .unwrap_or(call.name.as_str()); + attach_constructor_targets(ctx, targets, class_name) +} + +/// Core multi-strategy call target resolution — see `resolve_call_targets` for +/// the public entry point (which additionally applies constructor attribution). +fn resolve_call_targets_core<'a>( + ctx: &EdgeContext<'a>, + call: &CallInfo, + rel_path: &str, + imported_from: Option<&str>, + type_map: &HashMap<&str, (&str, f64)>, + caller_name: &str, + imported_original_names: &HashMap<&str, &str>, ) -> Vec<&'a NodeInfo> { // Flagged dynamic calls use synthetic names like "". Short-circuit // so they never accidentally match a real symbol via name lookup. @@ -889,10 +1082,15 @@ fn resolve_call_targets<'a>( return vec![]; } + // When the call site uses a renamed import binding (`import { X as Y }`), + // the imported file's actual symbol is declared under the *original* name + // (X) — look that up instead of the local alias the call site wrote (#1730). + let target_name = imported_original_names.get(call.name.as_str()).copied().unwrap_or(call.name.as_str()); + // 1. Import-aware resolution if let Some(imp_file) = imported_from { let targets = ctx.nodes_by_name_and_file - .get(&(call.name.as_str(), imp_file)) + .get(&(target_name, imp_file)) .cloned().unwrap_or_default(); if !targets.is_empty() { return targets; } } @@ -920,29 +1118,43 @@ fn resolve_call_targets<'a>( if !pre_qualified.is_empty() { return pre_qualified; } } - // 2. Same-file resolution - let targets = ctx.nodes_by_name_and_file + // 2. Same-file resolution. A receiver — concrete (`obj.x()`) or + // `this`/`self`/`super` — means the call is logically "invoke a member of + // some instance", which a class/interface/struct/etc. declaration can + // never satisfy; restrict those to definitively callable kinds + // (`is_callable_kind`) so an unrelated same-file type declaration that + // merely shares the call's name can never pre-empt a legitimate target + // that a more specific resolution tier (receiver typing, the + // Object.defineProperty accessor fallback, etc.) would otherwise find. A + // genuinely bare call (no receiver at all) is left unfiltered: at this + // layer it is indistinguishable from a `new ClassName()` constructor + // invocation, which legitimately targets a class-kind definition — + // kind-filtering it would break constructor-call resolution (#1888). + // Mirrors resolveCallTargets in call-resolver.ts. + let bare_matches = ctx.nodes_by_name_and_file .get(&(call.name.as_str(), rel_path)) .cloned().unwrap_or_default(); + let targets: Vec<&NodeInfo> = if call.receiver.is_some() { + bare_matches.into_iter().filter(|n| is_callable_kind(&n.kind)).collect() + } else { + bare_matches + }; if !targets.is_empty() { return targets; } // 3. Type-aware resolution via receiver → type map. - // Strips "this." prefix so `this.repo.method()` resolves via typeMap["repo"] - // or typeMap["this.repo"] (both seeded by the class-field extractor). + // Strips "this."/"self." prefix so `this.repo.method()` / `self.repo.method()` + // resolves via typeMap["repo"] or typeMap["this.repo"] (both seeded by the + // class-field extractor — the Rust extractor seeds "StructName.repo", #1876). if let Some(ref receiver) = call.receiver { - let effective_receiver = if receiver.starts_with("this.") { - &receiver["this.".len()..] - } else { - receiver.as_str() - }; + let effective_receiver = strip_instance_prefix(receiver); // Phase 8.3f: callee-scoped rest-param key (`callee::restName`) avoids // same-name rest-binding collisions across functions in the same file (#1358). let rest_param_key = format!("{}::{}", caller_name, effective_receiver); // Class-scoped key (`ClassName.prop`) seeded by `this.prop = new Ctor()` and // field annotations — prevents false edges when multiple classes define the same - // property name (issues #1323, #1458). Consulted first for `this.` receivers so - // bare fallback keys (confidence 0.6) don't shadow the correct per-class entry. - let class_scoped_key = if receiver.starts_with("this.") && !caller_name.is_empty() { + // property name (issues #1323, #1458). Consulted first for `this.`/`self.` receivers + // so bare fallback keys (confidence 0.6) don't shadow the correct per-class entry. + let class_scoped_key = if effective_receiver != receiver.as_str() && !caller_name.is_empty() { caller_name .rfind('.') .map(|dot| format!("{}.{}", &caller_name[..dot], effective_receiver)) @@ -964,6 +1176,13 @@ fn resolve_call_targets<'a>( // Use typeMap-resolved type or inline-new-extracted type, whichever is available. let resolved_type = type_lookup.map(|&(t, _)| t).or(inline_new_type.as_deref()); if let Some(type_name) = resolved_type { + // The resolved type name can itself be a renamed import binding + // (e.g. `import { Foo as Bar } from './x'; const y = new Bar(); + // y.method()` seeds typeMap['y'] = 'Bar') — de-alias before + // building the qualified lookup key, since the symbol table + // stores definitions under the declared name (`Foo.method`), + // not the local alias (#1825). + let type_name = imported_original_names.get(type_name).copied().unwrap_or(type_name); let qualified = format!("{}.{}", type_name, call.name); let typed: Vec<&NodeInfo> = ctx.nodes_by_name .get(qualified.as_str()) @@ -995,7 +1214,16 @@ fn resolve_call_targets<'a>( // Guard: skip when inline_new_type is Some — mirrors TS `!typeName` which is false when the // inline-new regex extracted a type (e.g. `(new Foo).bar()` → typeName='Foo' → skip). if type_lookup.is_none() && inline_new_type.is_none() { - let qualified = format!("{}.{}", effective_receiver, call.name); + // The receiver itself can be a renamed import binding (`import { + // NamespaceObj as NsAlias } from './helpers.js'; NsAlias.doThing()`) + // — de-alias before building the qualified lookup key, since the + // symbol table stores the object literal under its declared name + // (`NamespaceObj.doThing`), not the importing file's local alias (#1825). + let dealiased_receiver = imported_original_names + .get(effective_receiver) + .copied() + .unwrap_or(effective_receiver); + let qualified = format!("{}.{}", dealiased_receiver, call.name); let direct: Vec<&NodeInfo> = ctx.nodes_by_name .get(qualified.as_str()) .map(|v| v.iter() @@ -1095,12 +1323,7 @@ fn resolve_call_targets<'a>( } // First try exact name match (e.g. an unqualified function named "area"). - let exact: Vec<&NodeInfo> = ctx.nodes_by_name - .get(call.name.as_str()) - .map(|v| v.iter() - .filter(|n| resolve::compute_confidence(rel_path, &n.file, None) >= 0.5) - .copied().collect()) - .unwrap_or_default(); + let exact = resolve_exact_global_match(ctx, call.name.as_str(), rel_path, call.receiver.as_deref()); if !exact.is_empty() { return exact; } // Class-scoped exact lookup: prefer `ClassName.method` when the caller is a qualified @@ -1164,6 +1387,82 @@ fn resolve_call_targets<'a>( Vec::new() } +// ── Constructor-call attribution (#1892) ────────────────────────────────── + +/// Per-language constructor method identifier, keyed by file extension. Used +/// to build the qualified `ClassName.` lookup key that +/// attributes a `new ClassName()` (or bare `ClassName()`, for the keyword-less +/// languages below) call site to the class's own constructor **method**, +/// rather than only the class declaration node it already resolves to. +/// Returns `None` when the extension is unrecognised (or has no extension at +/// all). For Java/C#/Dart/Groovy the constructor's own identifier equals the +/// class name (`class Foo { Foo(...) {} }`), so those arms return +/// `class_name` itself rather than a fixed keyword. Mirrors +/// `CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION` in strategy.ts. +/// +/// Deliberately excludes languages whose extractor does not emit an explicit +/// constructor definition at all (Kotlin, Swift, Scala) or does not track +/// object-construction call sites at all (C++) — for those, the class-node +/// edge already produced by `resolve_call_targets_core` is the only +/// attribution possible. +fn constructor_local_name<'b>(file: &str, class_name: &'b str) -> Option<&'b str> { + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + match ext { + "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" => Some("constructor"), + "py" | "pyi" => Some("__init__"), + "php" | "phtml" => Some("__construct"), + "java" | "cs" | "dart" | "groovy" | "gvy" => Some(class_name), + _ => None, + } +} + +/// Resolve the constructor **method** node for a class target, if the class +/// declares one explicitly. Scoped to the class's own file (`file`) so an +/// unrelated same-named constructor elsewhere can never match. +fn resolve_constructor_target<'a>( + ctx: &EdgeContext<'a>, + file: &str, + class_name: &str, +) -> Option<&'a NodeInfo> { + let local_name = constructor_local_name(file, class_name)?; + let qualified = format!("{}.{}", class_name, local_name); + ctx.nodes_by_name_and_file + .get(&(qualified.as_str(), file)) + .and_then(|v| v.iter().find(|n| n.kind == "method")) + .copied() +} + +/// Additive constructor-call attribution: for every `class`-kind target in +/// `targets`, also resolve that class's own constructor method (if one is +/// explicitly declared) and append it. Mirrors `attachConstructorTargets` in +/// strategy.ts. +/// +/// Additive, not a replacement: the class-node target is always left standing +/// — the DB-driven RTA fallback (incremental rebuilds, see `cha.ts`'s +/// `buildChaContextFromDb`) reads instantiation evidence from `calls` edges +/// targeting class-kind nodes, and a class with no explicit constructor +/// legitimately has nothing else to attribute the call to. +fn attach_constructor_targets<'a>( + ctx: &EdgeContext<'a>, + mut targets: Vec<&'a NodeInfo>, + class_name: &str, +) -> Vec<&'a NodeInfo> { + let mut seen_ids: HashSet = targets.iter().map(|t| t.id).collect(); + let mut extra = Vec::new(); + for target in &targets { + if target.kind != "class" { + continue; + } + if let Some(ctor) = resolve_constructor_target(ctx, target.file.as_str(), class_name) { + if seen_ids.insert(ctor.id) { + extra.push(ctor); + } + } + } + targets.extend(extra); + targets +} + /// Languages where bare `foo()` calls inside a class method are lexically scoped /// to the module, not the class — there is no implicit this/class binding. /// Mirrors `MODULE_SCOPED_BARE_CALL_EXTENSIONS` in call-resolver.ts. @@ -1174,6 +1473,19 @@ fn is_module_scoped_language(rel_path: &str) -> bool { } } +/// Instance-reference prefixes that qualify a receiver chain as "this object's +/// own field" (`this.repo`, `self.repo`) — `this` for JS/TS/Java/C#-family +/// languages, `self` for Python/Rust/Swift-family languages. Stripped the same +/// way so `X.repo.method()` resolves via type_map["repo"] regardless of which +/// keyword the source language uses. Mirrors `stripInstancePrefix` in +/// strategy.ts (#1876). +fn strip_instance_prefix(receiver: &str) -> &str { + receiver + .strip_prefix("this.") + .or_else(|| receiver.strip_prefix("self.")) + .unwrap_or(receiver) +} + /// Extract the constructor name from an inline `new` receiver expression. /// /// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` used in call-resolver.ts. @@ -1276,9 +1588,13 @@ fn emit_receiver_edge( samefile_candidates } else { // Fall back to any cross-file class/struct/interface candidate. + // Cross-language candidates are never legitimate receiver targets + // (#1783) — a `new Foo()` in one language can't statically resolve to + // an unrelated same-named class in another. Mirrors JS resolveReceiverEdge. ctx.nodes_by_name.get(effective_receiver).cloned().unwrap_or_default() .into_iter() - .filter(|n| ctx.receiver_kinds.contains(n.kind.as_str())) + .filter(|n| ctx.receiver_kinds.contains(n.kind.as_str()) + && resolve::is_same_language_family(rel_path, &n.file)) .collect() }; @@ -1299,9 +1615,56 @@ fn emit_receiver_edge( } } +/// Resolve extends/implements target candidates for a class hierarchy edge. +/// +/// Mirrors the JS `resolveHierarchyTargets` in `call-resolver.ts` (#1812): +/// a bare heritage-clause name previously matched every same-named node in +/// the graph regardless of file or language, producing false cross-file +/// (even cross-language) hierarchy edges for common type names. Priority: +/// 1. Same-file declaration, when `name` is not itself an import artifact. +/// 2. The file's actually-resolved import for `name` (barrel-traced). +/// 3. Last resort: a same-language-family global-by-name match (#1783), +/// first candidate only — a heritage clause names exactly one type. +fn resolve_hierarchy_targets<'a>( + ctx: &EdgeContext<'a>, + name: &str, + rel_path: &str, + imported_names: &HashMap<&str, &str>, + target_kinds: &[&str], +) -> Vec<&'a NodeInfo> { + let samefile_all: Vec<&NodeInfo> = ctx.nodes_by_name_and_file + .get(&(name, rel_path)) + .cloned().unwrap_or_default(); + let is_local_definition = !samefile_all.is_empty() && !imported_names.contains_key(name); + if is_local_definition { + return samefile_all.into_iter() + .filter(|n| target_kinds.contains(&n.kind.as_str())) + .collect(); + } + + if let Some(imported_from) = imported_names.get(name) { + let imported_candidates: Vec<&NodeInfo> = ctx.nodes_by_name_and_file + .get(&(name, *imported_from)) + .cloned().unwrap_or_default() + .into_iter() + .filter(|n| target_kinds.contains(&n.kind.as_str())) + .collect(); + if !imported_candidates.is_empty() { + return imported_candidates; + } + } + + ctx.nodes_by_name.get(name).cloned().unwrap_or_default() + .into_iter() + .filter(|n| target_kinds.contains(&n.kind.as_str()) && resolve::is_same_language_family(rel_path, &n.file)) + .take(1) + .collect() +} + /// Emit extends and implements edges for class hierarchy declarations. fn emit_hierarchy_edges( ctx: &EdgeContext, file_input: &FileEdgeInput, rel_path: &str, + imported_names: &HashMap<&str, &str>, edges: &mut Vec, ) { for cls in &file_input.classes { @@ -1312,9 +1675,7 @@ fn emit_hierarchy_edges( let Some(source) = source_row else { continue }; if let Some(ref extends_name) = cls.extends { - let targets = ctx.nodes_by_name.get(extends_name.as_str()) - .map(|v| v.iter().filter(|n| EXTENDS_TARGET_KINDS.contains(&n.kind.as_str())).collect::>()) - .unwrap_or_default(); + let targets = resolve_hierarchy_targets(ctx, extends_name, rel_path, imported_names, EXTENDS_TARGET_KINDS); for t in targets { edges.push(ComputedEdge { source_id: source.id, target_id: t.id, @@ -1324,9 +1685,7 @@ fn emit_hierarchy_edges( } } if let Some(ref implements_name) = cls.implements { - let targets = ctx.nodes_by_name.get(implements_name.as_str()) - .map(|v| v.iter().filter(|n| IMPLEMENTS_TARGET_KINDS.contains(&n.kind.as_str())).collect::>()) - .unwrap_or_default(); + let targets = resolve_hierarchy_targets(ctx, implements_name, rel_path, imported_names, IMPLEMENTS_TARGET_KINDS); for t in targets { edges.push(ComputedEdge { source_id: source.id, target_id: t.id, @@ -1351,6 +1710,34 @@ pub struct ImportInfo { pub dynamic_import: bool, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: bool, + /// Local names (subset of `names`) marked type-only via an inline + /// per-specifier `type`/`typeof` modifier (`import { type X }`), as + /// distinct from a whole-statement `import type { X }` (already covered + /// by `type_only`, #1813). + #[napi(js_name = "typeOnlyNames")] + pub type_only_names: Vec, + /// `{ local, imported }` pairs for `import { X as Y }` specifiers — + /// mirrors `Import.renamedImports` (#1730). Without this, symbol-level + /// lookups in `emit_named_symbol_edges`/`emit_barrel_through_edges` would + /// search the target file for the local (post-rename) name instead of + /// the name actually declared there, silently failing to find it (#1847). + #[napi(js_name = "renamedImports")] + pub renamed_imports: Vec, +} + +impl ImportNameSource for ImportInfo { + fn names(&self) -> &[String] { + &self.names + } + fn renamed_imports(&self) -> &[RenamedImport] { + &self.renamed_imports + } + fn is_type_only(&self) -> bool { + self.type_only + } + fn type_only_names(&self) -> &[String] { + &self.type_only_names + } } #[napi(object)] @@ -1371,6 +1758,10 @@ pub struct ReexportEntryInput { pub names: Vec, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: bool, + /// `{ local, imported }` pairs for `export { X as Y } from …` specifiers + /// within this entry — see `barrel_resolution::ReexportRef::renames` (#1823). + #[napi(ts_type = "RenamedImport[] | undefined")] + pub renames: Option>, } #[napi(object)] @@ -1394,14 +1785,18 @@ pub struct ResolvedImportEntry { } /// A symbol node entry for type-only import resolution. -/// Maps (name, file) → nodeId so the native engine can create symbol-level -/// `imports-type` edges (parity with the JS `buildImportEdges` path). +/// Maps (name, file) → (nodeId, kind) so the native engine can create +/// symbol-level `imports-type` edges (parity with the JS `buildImportEdges` +/// path) — `kind` lets it also credit plain imports of TypeScript +/// interface/type-alias declarations, not just `import type` statements +/// (#1833). #[napi(object)] pub struct SymbolNodeEntry { pub name: String, pub file: String, #[napi(js_name = "nodeId")] pub node_id: u32, + pub kind: String, } /// Shared lookup context for import edge building. @@ -1411,9 +1806,15 @@ struct ImportEdgeContext<'a> { file_node_map: HashMap<&'a str, u32>, barrel_set: HashSet<&'a str>, file_defs: HashMap<&'a str, HashSet<&'a str>>, - /// Symbol node lookup: (name, file) → node ID. - /// Used to create symbol-level `imports-type` edges for type-only imports. - symbol_node_map: HashMap<(&'a str, &'a str), u32>, + /// Symbol node lookup: (name, file) → (node ID, kind). + /// Used to create symbol-level `imports-type` edges for type-only imports, + /// and — via `kind` — for plain imports resolving to a TypeScript + /// interface/type-alias declaration (#1833). + /// + /// Owned keys (rather than `&'a str`) because a barrel-rename lookup key + /// (#1823) is a freshly-resolved name that doesn't borrow from `'a` input + /// data. + symbol_node_map: HashMap<(String, String), (u32, String)>, } impl<'a> ImportEdgeContext<'a> { @@ -1451,7 +1852,10 @@ impl<'a> ImportEdgeContext<'a> { let mut symbol_node_map = HashMap::with_capacity(symbol_nodes.len()); for entry in symbol_nodes { - symbol_node_map.insert((entry.name.as_str(), entry.file.as_str()), entry.node_id); + symbol_node_map.insert( + (entry.name.clone(), entry.file.clone()), + (entry.node_id, entry.kind.clone()), + ); } Self { resolved, reexport_map, file_node_map, barrel_set, file_defs, symbol_node_map } @@ -1467,6 +1871,7 @@ impl<'a> BarrelContext for ImportEdgeContext<'a> { source: re.source.as_str(), names: &re.names, wildcard_reexport: re.wildcard_reexport, + renames: re.renames.as_deref().unwrap_or(&[]), }) .collect() }) @@ -1518,17 +1923,6 @@ pub fn build_import_edges( // ── build_import_edges helpers ────────────────────────────────────────── -/// Strip a `"* as "` / `"*\tas "` prefix from an import name so the bare -/// symbol can be looked up against the target's exports. JS equivalent: -/// `name.replace(/^\*\s+as\s+/, '')`. -fn strip_star_as_prefix(name: &str) -> &str { - if name.starts_with("* as ") || name.starts_with("*\tas ") { - &name[5..] - } else { - name - } -} - /// Classify an import into its edge kind: reexports / imports-type / /// dynamic-imports / imports. Mirrors the JS classifier in `build-edges.ts`. fn classify_import_edge_kind(imp: &ImportInfo) -> &'static str { @@ -1543,41 +1937,91 @@ fn classify_import_edge_kind(imp: &ImportInfo) -> &'static str { } } -/// For a `type` import targeting a barrel or resolved file, emit one -/// symbol-level `imports-type` edge per named symbol so the target symbols -/// receive fan-in credit and aren't misclassified as dead code. -fn emit_type_only_symbol_edges( +/// True for a named (non-wildcard) re-export — `export { X } from 'Y'` or +/// `export { X as Z } from 'Y'`. Wildcard re-exports (`export * from 'Y'`) +/// carry no specific names, so they're excluded here and handled instead by +/// the file-level `reexports` edge + the query layer's full-export fallback. +fn is_named_reexport(imp: &ImportInfo) -> bool { + imp.reexport && !imp.wildcard_reexport +} + +/// For a `type` import or a named re-export targeting a barrel or resolved +/// file, emit one symbol-level edge per named symbol so the target symbols +/// receive fan-in credit and aren't misclassified as dead code +/// (`imports-type`, #1724), or so `codegraph exports` can report the +/// precise re-export surface instead of the target's full export list +/// (`reexports`, #1742). `kind` selects which edge kind to emit. +/// +/// For `kind == "imports-type"`, a specifier gets an edge when either it's +/// actually marked type-only (whole-statement or inline per-specifier, +/// #1813 — a mixed `import { value, type Foo }` must not credit `value` on +/// this basis alone), or the resolved target is a TypeScript +/// interface/type-alias declaration (`is_type_erased_import_target`) — those +/// kinds are erased before runtime, so a plain `import { Foo } from 'y'` (no +/// `type` keyword) is the only consumption signal `codegraph exports` can +/// observe for them (#1833). +/// +/// Looks up each specifier's *original* declared name via `import_name_pairs` +/// rather than the local (possibly renamed) binding — for `export { X as Z }` +/// this is already `X` (`imp.names` holds the original for export +/// specifiers), but for a renamed value/type import (`import type { X as Y }`) +/// the original name only exists in `imp.renamed_imports`; searching under the +/// local alias `Y` would never find a match in the target file (#1847). The +/// emitted edge (and downstream `reexportedSymbols` entry) is reported under +/// the symbol's own declared name in both cases, not the local/barrel alias. +/// +/// When `resolved_path` is itself a barrel that renamed the requested name +/// further down its own reexport chain (`export { X as Y } from …`), +/// `resolve_barrel_export` reports the name actually declared in the +/// resolved file — which may differ from `original` — so the lookup below +/// must use that reported name, not `original`, against the barrel target +/// (#1823). +fn emit_named_symbol_edges( edges: &mut Vec, file_input: &ImportEdgeFileInput, imp: &ImportInfo, resolved_path: &str, + kind: &str, ctx: &ImportEdgeContext, ) { - if !imp.type_only || ctx.symbol_node_map.is_empty() { + if ctx.symbol_node_map.is_empty() { return; } - for name in &imp.names { - let clean_name = strip_star_as_prefix(name); + for (_local, original, type_only) in import_name_pairs(imp) { let barrel_target = if ctx.barrel_set.contains(resolved_path) { let mut visited = HashSet::new(); - barrel_resolution::resolve_barrel_export(ctx, resolved_path, clean_name, &mut visited) + barrel_resolution::resolve_barrel_export(ctx, resolved_path, &original, &mut visited) } else { None }; - let sym_id = barrel_target - .as_deref() - .and_then(|f| ctx.symbol_node_map.get(&(clean_name, f))) - .or_else(|| ctx.symbol_node_map.get(&(clean_name, resolved_path))); - if let Some(&id) = sym_id { - edges.push(ComputedEdge { - source_id: file_input.file_node_id, - target_id: id, - kind: "imports-type".to_string(), - confidence: 1.0, - dynamic: 0, - dynamic_kind: None, - }); + let (target_name, target_file) = match &barrel_target { + Some(resolved) + if ctx + .symbol_node_map + .contains_key(&(resolved.name.clone(), resolved.file.clone())) => + { + (resolved.name.clone(), resolved.file.clone()) + } + _ => (original, resolved_path.to_string()), + }; + let Some((id, sym_kind)) = ctx.symbol_node_map.get(&(target_name, target_file.clone())) + else { + continue; + }; + if kind == "imports-type" + && !type_only + && !crate::shared::constants::is_type_erased_import_target(sym_kind, &target_file) + { + continue; } + edges.push(ComputedEdge { + source_id: file_input.file_node_id, + target_id: *id, + kind: kind.to_string(), + confidence: 1.0, + dynamic: 0, + dynamic_kind: None, + }); } } @@ -1602,17 +2046,16 @@ fn emit_barrel_through_edges( _ => "imports", }; let mut resolved_sources: HashSet = HashSet::new(); - for name in &imp.names { - let clean_name = strip_star_as_prefix(name); + for (_local, original, _type_only) in import_name_pairs(imp) { let mut visited = HashSet::new(); let actual = barrel_resolution::resolve_barrel_export( ctx, resolved_path, - clean_name, + &original, &mut visited, ); let actual_source = match actual { - Some(s) => s, + Some(resolved) => resolved.file, None => continue, }; if actual_source == resolved_path || resolved_sources.contains(&actual_source) { @@ -1662,7 +2105,15 @@ fn process_single_import( dynamic: 0, dynamic_kind: None, }); - emit_type_only_symbol_edges(edges, file_input, imp, resolved_path, ctx); + // Always attempted (not just for `import type`/inline-`type` specifiers) — + // emit_named_symbol_edges also credits plain specifiers that resolve to a + // TypeScript interface/type-alias declaration (#1833). + if !imp.reexport { + emit_named_symbol_edges(edges, file_input, imp, resolved_path, "imports-type", ctx); + } + if is_named_reexport(imp) { + emit_named_symbol_edges(edges, file_input, imp, resolved_path, "reexports", ctx); + } emit_barrel_through_edges(edges, file_input, imp, resolved_path, edge_kind, ctx); } @@ -1688,6 +2139,55 @@ mod import_edge_tests { type_only, dynamic_import: dynamic, wildcard_reexport: false, + type_only_names: vec![], + renamed_imports: vec![], + } + } + + /// A mixed import (`import { value, type Foo } from 'src'`) where only + /// `type_only_names` carries the inline-modifier names (#1813). + fn make_import_with_type_only_names( + source: &str, + names: Vec<&str>, + type_only_names: Vec<&str>, + ) -> ImportInfo { + ImportInfo { + source: source.to_string(), + names: names.into_iter().map(|s| s.to_string()).collect(), + reexport: false, + type_only: false, + dynamic_import: false, + wildcard_reexport: false, + type_only_names: type_only_names.into_iter().map(|s| s.to_string()).collect(), + renamed_imports: vec![], + } + } + + /// A renamed import (`import { X as Y } from 'src'`, optionally `import + /// type`) — `names` carries the local (post-rename) binding `Y`, and + /// `renamed_imports` maps it back to the original declared name `X` + /// (#1730, #1847). + fn make_import_with_renames( + source: &str, + names: Vec<&str>, + renames: Vec<(&str, &str)>, + type_only: bool, + ) -> ImportInfo { + ImportInfo { + source: source.to_string(), + names: names.into_iter().map(|s| s.to_string()).collect(), + reexport: false, + type_only, + dynamic_import: false, + wildcard_reexport: false, + type_only_names: vec![], + renamed_imports: renames + .into_iter() + .map(|(local, imported)| RenamedImport { + local: local.to_string(), + imported: imported.to_string(), + }) + .collect(), } } @@ -1731,6 +2231,123 @@ mod import_edge_tests { assert_eq!(edges[0].kind, "reexports"); } + #[test] + fn named_reexport_emits_symbol_level_edge() { + // `export { foo } from './utils'` in src/index.ts, where `foo` is a + // specific symbol defined in src/utils.ts. Alongside the file-level + // `reexports` edge, a symbol-level `reexports` edge should point at + // `foo`'s own node — not at every export of utils.ts (#1742). + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + kind: "function".to_string(), + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + // File-level edge: index.ts -> utils.ts file node. + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + // Symbol-level edge: index.ts -> foo's own node. + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + } + + #[test] + fn wildcard_reexport_emits_no_symbol_level_edge() { + // `export * from './utils'` carries no specific names, so only the + // file-level `reexports` edge is emitted — the query layer falls + // back to the target's full export list for genuine wildcards. + let files = vec![make_file("src/index.ts", 1, vec![ + ImportInfo { + source: "./utils".to_string(), + names: vec![], + reexport: true, + type_only: false, + dynamic_import: false, + wildcard_reexport: true, + type_only_names: vec![], + renamed_imports: vec![], + }, + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + kind: "function".to_string(), + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + } + + #[test] + fn renamed_reexport_resolves_original_name() { + // `export { foo as bar } from './utils'` — the JS extractor stores + // the *original* declaration name ("foo") in `names`, not the + // external alias ("bar"). The symbol-level edge must resolve + // against foo's own node. + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![ + SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + kind: "function".to_string(), + }, + // A decoy under the external alias name must NOT be matched. + SymbolNodeEntry { + name: "bar".to_string(), + file: "src/utils.ts".to_string(), + node_id: 100, + kind: "function".to_string(), + }, + ]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + } + #[test] fn type_only_edge() { let files = vec![make_file("src/app.ts", 1, vec![ @@ -1744,6 +2361,242 @@ mod import_edge_tests { assert_eq!(edges[0].kind, "imports-type"); } + #[test] + fn renamed_type_import_resolves_original_name() { + // `import type { Config as CfgType } from './types'` — `names` holds + // the local alias "CfgType", but `Config` is the name actually + // declared in types.ts. The symbol-level `imports-type` edge must + // resolve against Config's own node, not fail to find "CfgType" + // there (#1847). + let files = vec![make_file("src/app.ts", 1, vec![ + make_import_with_renames("./types", vec!["CfgType"], vec![("CfgType", "Config")], true), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/app.ts", "./types", "src/types.ts")]; + let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/types.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "Config".to_string(), + file: "src/types.ts".to_string(), + node_id: 77, + kind: "interface".to_string(), + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].kind, "imports-type"); + assert_eq!(edges[0].target_id, 2); + assert_eq!(edges[1].kind, "imports-type"); + assert_eq!(edges[1].target_id, 77); + } + + #[test] + fn renamed_import_through_barrel_resolves_original_name() { + // `import { Config as CfgType } from './barrel'` where './barrel' + // does `export { Config } from './types'`. Barrel-through resolution + // must look up "Config" (the original name) in the barrel's own + // export map, not the local alias "CfgType", which only exists in + // app.ts (#1847). + let files = vec![ + make_file("src/app.ts", 1, vec![ + make_import_with_renames("./barrel", vec!["CfgType"], vec![("CfgType", "Config")], false), + ], vec![]), + make_file("src/barrel.ts", 10, vec![], vec![]), + make_file("src/types.ts", 20, vec![], vec!["Config"]), + ]; + let resolved = vec![make_resolved("/root/src/app.ts", "./barrel", "src/barrel.ts")]; + let reexports = vec![FileReexports { + file: "src/barrel.ts".to_string(), + reexports: vec![ReexportEntryInput { + source: "src/types.ts".to_string(), + names: vec!["Config".to_string()], + wildcard_reexport: false, + renames: None, + }], + }]; + let node_ids = vec![ + make_node_entry("src/app.ts", 1), + make_node_entry("src/barrel.ts", 10), + make_node_entry("src/types.ts", 20), + ]; + let barrels = vec!["src/barrel.ts".to_string()]; + + let edges = build_import_edges( + files, + resolved, + reexports, + node_ids, + barrels, + "/root".to_string(), + None, + ); + assert_eq!(edges.len(), 2); + // First: direct import to the barrel. + assert_eq!(edges[0].kind, "imports"); + assert_eq!(edges[0].target_id, 10); + // Second: barrel-through to the actual source (types.ts), resolved + // via the original name "Config", not the local alias "CfgType". + assert_eq!(edges[1].kind, "imports"); + assert_eq!(edges[1].target_id, 20); + assert_eq!(edges[1].confidence, 0.9); + } + + #[test] + fn mixed_import_inline_type_modifier_credits_only_flagged_name() { + // `import { value, type Foo } from './mixed'` — only `Foo` carries + // the inline per-specifier `type` modifier, so only `Foo` should get + // a symbol-level `imports-type` edge; `value` must not (#1813). The + // file-level edge stays `imports` since the statement as a whole + // isn't fully type-only. + let files = vec![make_file("src/app.ts", 1, vec![ + make_import_with_type_only_names("./mixed", vec!["value", "Foo"], vec!["Foo"]), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/app.ts", "./mixed", "src/mixed.ts")]; + let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/mixed.ts", 2)]; + let symbol_nodes = vec![ + SymbolNodeEntry { + name: "Foo".to_string(), + file: "src/mixed.ts".to_string(), + node_id: 50, + kind: "function".to_string(), + }, + SymbolNodeEntry { + name: "value".to_string(), + file: "src/mixed.ts".to_string(), + node_id: 51, + kind: "function".to_string(), + }, + ]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].kind, "imports"); + assert_eq!(edges[1].kind, "imports-type"); + assert_eq!(edges[1].target_id, 50); + } + + #[test] + fn plain_import_of_ts_interface_credits_imports_type_edge() { + // `import { Foo } from './types'` — no `type` keyword — where `Foo` + // is a TypeScript interface. Interfaces are erased before runtime, so + // this plain import is the only observable consumption signal + // `codegraph exports` can rely on; it must be credited exactly like + // `import type { Foo }` would be (#1833). + let files = vec![make_file( + "src/app.ts", + 1, + vec![make_import("./types", vec!["Foo"], false, false, false)], + vec![], + )]; + let resolved = vec![make_resolved("/root/src/app.ts", "./types", "src/types.ts")]; + let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/types.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "Foo".to_string(), + file: "src/types.ts".to_string(), + node_id: 50, + kind: "interface".to_string(), + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].kind, "imports"); + assert_eq!(edges[1].kind, "imports-type"); + assert_eq!(edges[1].target_id, 50); + } + + #[test] + fn plain_import_of_ts_value_symbol_gets_no_symbol_level_edge() { + // `import { helper } from './utils'` where `helper` is a plain + // function (not an interface/type alias). Consumption credit for a + // value symbol must still come exclusively from a real `calls` edge + // — merely importing it must not fabricate one (#1833 must not + // regress the existing value-import behaviour). + let files = vec![make_file( + "src/app.ts", + 1, + vec![make_import("./utils", vec!["helper"], false, false, false)], + vec![], + )]; + let resolved = vec![make_resolved("/root/src/app.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "helper".to_string(), + file: "src/utils.ts".to_string(), + node_id: 50, + kind: "function".to_string(), + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, "imports"); + } + + #[test] + fn plain_import_of_non_typescript_interface_gets_no_symbol_level_edge() { + // A plain import resolving to an 'interface'-kind node in a + // non-TypeScript file (e.g. a Go `type ... interface {}`) must not be + // credited by this heuristic — those kinds are runtime-observable in + // other languages, so crediting on mere import would mask genuinely + // dead code instead of fixing a false positive (#1833 is scoped to + // TypeScript's compile-time-only interfaces/type aliases). + let files = vec![make_file( + "src/app.ts", + 1, + vec![make_import("./iface", vec!["Reader"], false, false, false)], + vec![], + )]; + let resolved = vec![make_resolved("/root/src/app.ts", "./iface", "src/iface.go")]; + let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/iface.go", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "Reader".to_string(), + file: "src/iface.go".to_string(), + node_id: 50, + kind: "interface".to_string(), + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, "imports"); + } + #[test] fn dynamic_import_edge() { let files = vec![make_file("src/app.ts", 1, vec![ @@ -1796,6 +2649,7 @@ mod import_edge_tests { source: "src/utils.ts".to_string(), names: vec!["foo".to_string()], wildcard_reexport: false, + renames: None, }], }]; let node_ids = vec![ @@ -1834,6 +2688,7 @@ mod import_edge_tests { source: "src/mid.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }, FileReexports { @@ -1842,6 +2697,7 @@ mod import_edge_tests { source: "src/deep.ts".to_string(), names: vec!["deep".to_string()], wildcard_reexport: false, + renames: None, }], }, ]; @@ -1875,6 +2731,7 @@ mod import_edge_tests { source: "src/b.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }, FileReexports { @@ -1883,6 +2740,7 @@ mod import_edge_tests { source: "src/a.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }, ]; @@ -1914,6 +2772,7 @@ mod import_edge_tests { source: "src/helpers.ts".to_string(), names: vec![], wildcard_reexport: true, + renames: None, }], }]; let node_ids = vec![ @@ -1946,6 +2805,7 @@ mod import_edge_tests { source: "src/real.ts".to_string(), names: vec!["a".to_string(), "b".to_string()], wildcard_reexport: false, + renames: None, }], }]; let node_ids = vec![ @@ -1959,6 +2819,47 @@ mod import_edge_tests { // 1 direct import + 1 barrel-through (deduped, not 2) assert_eq!(edges.len(), 2); } + + /// `export { realName as friendlyName } from './underlying'` — a consumer + /// importing the barrel's external name `friendlyName` must produce a + /// barrel-through edge to `underlying.ts`, the file that actually + /// declares `realName` (#1823). + #[test] + fn renamed_barrel_reexport_resolution() { + let files = vec![ + make_file("src/app.ts", 1, vec![ + make_import("./barrel", vec!["friendlyName"], false, false, false), + ], vec![]), + make_file("src/barrel.ts", 10, vec![], vec![]), + make_file("src/underlying.ts", 20, vec![], vec!["realName"]), + ]; + let resolved = vec![make_resolved("/root/src/app.ts", "./barrel", "src/barrel.ts")]; + let reexports = vec![FileReexports { + file: "src/barrel.ts".to_string(), + reexports: vec![ReexportEntryInput { + source: "src/underlying.ts".to_string(), + names: vec!["realName".to_string()], + wildcard_reexport: false, + renames: Some(vec![RenamedImport { + local: "friendlyName".to_string(), + imported: "realName".to_string(), + }]), + }], + }]; + let node_ids = vec![ + make_node_entry("src/app.ts", 1), + make_node_entry("src/barrel.ts", 10), + make_node_entry("src/underlying.ts", 20), + ]; + let barrels = vec!["src/barrel.ts".to_string()]; + + let edges = build_import_edges(files, resolved, reexports, node_ids, barrels, "/root".to_string(), None); + assert_eq!(edges.len(), 2); + // Barrel-through edge resolves through the rename to underlying.ts. + assert_eq!(edges[1].target_id, 20); + assert_eq!(edges[1].confidence, 0.9); + assert_eq!(edges[1].kind, "imports"); + } } #[cfg(test)] @@ -2042,7 +2943,7 @@ mod call_edge_tests { vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!( @@ -2055,6 +2956,72 @@ mod call_edge_tests { assert_eq!(re.target_id, 2, "receiver edge target should be Calculator (id=2)"); } + /// Issue #1895: an object-literal-property value-ref call whose property + /// key (`key_expr`) is never independently confirmed to be invoked + /// anywhere (`x.resolve(...)`) must NOT produce a `calls` edge — merely + /// being wired into the object literal is not liveness. A sibling + /// property whose key IS invoked elsewhere (`table.reject(...)`) keeps + /// its edge. + #[test] + fn value_ref_edge_requires_key_invoked_elsewhere() { + let all_nodes = vec![ + node(1, "makeTable", "function", "factory.js", 1), + node(2, "neverRead", "function", "factory.js", 2), + node(3, "isRead", "function", "factory.js", 3), + node(4, "run", "function", "consumer.js", 1), + ]; + + let mut resolve_call = call("neverRead", 5, None); + resolve_call.dynamic = Some(true); + resolve_call.dynamic_kind = Some("value-ref".to_string()); + resolve_call.key_expr = Some("resolve".to_string()); + + let mut reject_call = call("isRead", 6, None); + reject_call.dynamic = Some(true); + reject_call.dynamic_kind = Some("value-ref".to_string()); + reject_call.key_expr = Some("reject".to_string()); + + let factory_file = make_file( + "factory.js", + 10, + vec![def("makeTable", "function", 1, 8)], + vec![resolve_call, reject_call], + vec![], + vec![], + ); + + // Evidence that `.reject(...)` is genuinely invoked somewhere, but + // `.resolve(...)` never is. + let consumer_file = make_file( + "consumer.js", + 20, + vec![def("run", "function", 1, 3)], + vec![call("reject", 2, Some("table"))], + vec![], + vec![], + ); + + let edges = build_call_edges( + vec![factory_file, consumer_file], + all_nodes, + vec![], + MAX_SOLVER_ITERATIONS, + ); + + let calls_never_read = edges.iter().any(|e| e.kind == "calls" && e.target_id == 2); + let calls_is_read = edges.iter().any(|e| e.kind == "calls" && e.target_id == 3); + assert!( + !calls_never_read, + "expected no calls edge to neverRead (key 'resolve' never invoked); got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + assert!( + calls_is_read, + "expected a calls edge to isRead (key 'reject' invoked in consumer.js); got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + } + /// Regression: when the same file has a `kind="function"` node for the /// effective receiver created by a destructured import (e.g. /// `const { Calculator } = require('./utils')`), that import artifact must @@ -2081,9 +3048,9 @@ mod call_edge_tests { ); // Mark `Calculator` as an imported name so the resolver treats the // same-file kind="function" node as an import artifact and falls through. - file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string() }]; + file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string(), imported: None }]; - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!( @@ -2117,7 +3084,7 @@ mod call_edge_tests { vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!( @@ -2127,6 +3094,65 @@ mod call_edge_tests { ); } + /// Issue #1783: the global (cross-file) receiver fallback had no + /// language-consistency check at all, so `Widget.render()` in a Python + /// caller with no same-file `Widget` definition could resolve to an + /// unrelated same-named class declared in a JS file purely by name. + #[test] + fn receiver_edge_rejects_cross_language_match() { + let all_nodes = vec![ + node(1, "main", "function", "widget.py", 3), + node(2, "Widget", "class", "widget.js", 1), + ]; + + let files = vec![make_file( + "widget.py", + 10, + vec![def("main", "function", 3, 8)], + vec![call("render", 7, Some("Widget"))], + vec![], + vec![], + )]; + + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + + let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); + assert!( + receiver_edge.is_none(), + "a Python caller must not resolve a receiver edge to an unrelated same-named JS class; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + } + + /// Same-language global receiver fallback must still work after the + /// #1783 language-scoping fix — only cross-language candidates are rejected. + #[test] + fn receiver_edge_still_resolves_same_language_cross_file_match() { + let all_nodes = vec![ + node(1, "main", "function", "widget.py", 3), + node(2, "Widget", "class", "widget_impl.py", 1), + ]; + + let files = vec![make_file( + "widget.py", + 10, + vec![def("main", "function", 3, 8)], + vec![call("render", 7, Some("Widget"))], + vec![], + vec![], + )]; + + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + + let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); + assert!( + receiver_edge.is_some(), + "same-language cross-file receiver fallback must still resolve; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + assert_eq!(receiver_edge.unwrap().target_id, 2); + } + /// Issue #1453: `this.logger.error()` inside `UserService.create` where the /// constructor seeded the class-scoped key `UserService.logger → Logger`. /// The resolver must fall back to the `ClassName.prop` typeMap key (#1323). @@ -2145,7 +3171,7 @@ mod call_edge_tests { vec![type_map_entry("UserService.logger", "Logger", 1.0)], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "expected calls edge UserService.create → Logger.error; got: {:?}", @@ -2169,7 +3195,7 @@ mod call_edge_tests { vec![type_map_entry("useRest::eerest", "E4", 0.85)], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "expected calls edge useRest → E4.e4 via rest-param key; got: {:?}", @@ -2193,7 +3219,7 @@ mod call_edge_tests { vec![], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( !edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "bare call must not resolve to same-class sibling in a module-scoped language" @@ -2216,7 +3242,7 @@ mod call_edge_tests { vec![], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "bare sibling call must resolve in a class-scoped language; got: {:?}", @@ -2241,7 +3267,7 @@ mod call_edge_tests { vec![], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "expected Geo.Shape.describe → Shape.area via bare class segment; got: {:?}", @@ -2249,6 +3275,67 @@ mod call_edge_tests { ); } + /// Issue #1863: several same-named object-literal `close() {}` methods + /// scattered under sibling directories two levels below the caller all + /// score the same 0.5 "grandparent proximity" confidence. A bare `close()` + /// call must not fan out into a `calls` edge to every one of them — a + /// genuine top-confidence tie is ambiguous and must resolve to nothing. + #[test] + fn global_fallback_tie_does_not_fan_out() { + let all_nodes = vec![ + node(1, "caller", "function", "src/presentation/caller.ts", 3), + node(2, "close", "method", "src/db/connection.ts", 10), + node(3, "close", "method", "src/domain/target2.ts", 20), + node(4, "close", "method", "src/features/target3.ts", 30), + ]; + let files = vec![make_file( + "src/presentation/caller.ts", + 10, + vec![def("caller", "function", 3, 8)], + vec![call("close", 5, None)], + vec![], + vec![], + )]; + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + assert!( + !edges.iter().any(|e| e.kind == "calls" && e.source_id == 1), + "ambiguous same-confidence candidates must not fan out into calls edges; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + } + + /// Companion to `global_fallback_tie_does_not_fan_out`: when one candidate + /// has a strictly higher confidence than the rest, the clear single winner + /// must still resolve — only genuine top-confidence ties are dropped. + #[test] + fn global_fallback_resolves_unambiguous_best_match() { + let all_nodes = vec![ + node(1, "caller", "function", "src/presentation/caller.ts", 3), + // Same directory as the caller → confidence 0.7, the clear winner. + node(2, "close", "method", "src/presentation/sibling.ts", 10), + // Two directories away → confidence 0.5, tied with each other but not with node 2. + node(3, "close", "method", "src/domain/target2.ts", 20), + node(4, "close", "method", "src/features/target3.ts", 30), + ]; + let files = vec![make_file( + "src/presentation/caller.ts", + 10, + vec![def("caller", "function", 3, 8)], + vec![call("close", 5, None)], + vec![], + vec![], + )]; + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + let call_edges: Vec<_> = edges.iter().filter(|e| e.kind == "calls" && e.source_id == 1).collect(); + assert_eq!( + call_edges.len(), + 1, + "expected exactly one calls edge (the unambiguous best match); got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + assert_eq!(call_edges[0].target_id, 2, "expected the same-directory candidate to win"); + } + /// Receiver-edge confidence must propagate the stored typeMap confidence /// (e.g. 0.85 from a pts property-write) instead of a flat 0.9 — mirrors /// `typeConfidence ?? (typeName ? 0.9 : 0.7)` in resolveReceiverEdge. @@ -2267,7 +3354,7 @@ mod call_edge_tests { vec![type_map_entry("calc", "Calculator", 0.85)], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let re = edges.iter().find(|e| e.kind == "receiver").expect("receiver edge"); assert!( (re.confidence - 0.85).abs() < 1e-9, @@ -2294,7 +3381,7 @@ mod call_edge_tests { vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!(receiver_edge.is_some(), "expected receiver edge for direct class-name receiver"); @@ -2340,7 +3427,7 @@ mod call_edge_tests { arg_name: "target".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2385,7 +3472,7 @@ mod call_edge_tests { this_arg: "handler".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2431,7 +3518,7 @@ mod call_edge_tests { enclosing_func: "iterPlain".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); for target in [1u32, 2u32] { assert!( @@ -2464,7 +3551,7 @@ mod call_edge_tests { vec![], vec![], ); - file.imported_names = vec![ImportedName { name: "e4".to_string(), file: "other.js".to_string() }]; + file.imported_names = vec![ImportedName { name: "e4".to_string(), file: "other.js".to_string(), imported: None }]; file.param_bindings = Some(vec![ParamBinding { callee: "f3".to_string(), arg_index: 0, @@ -2481,7 +3568,7 @@ mod call_edge_tests { value_name: "e4".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2523,7 +3610,7 @@ mod call_edge_tests { start_index: 0, }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2536,6 +3623,68 @@ mod call_edge_tests { edges.iter().map(|e| (e.source_id, e.target_id, &e.kind)).collect::>() ); } + + /// Regression for issue #1753: the points-to solver's fixed-point loop must + /// honor the caller-supplied `max_iterations` rather than a hardcoded value. + /// Mirrors the equivalent TS-side test in `tests/unit/points-to.test.ts`. + /// + /// Builds an 8-hop alias chain `a0=a1, a1=a2, ..., a6=a7, a7=handler` in this + /// exact (declaration) order. `build_points_to_map` processes constraints in + /// array order each pass, so a single hop propagates per iteration, moving + /// from the tail of the array backward to the front — resolving `a0` + /// requires exactly `chain_len` (8) iterations. + #[test] + fn max_iterations_caps_alias_chain_convergence() { + let chain_len: u32 = 8; + let mut fn_ref_bindings: Vec = (0..chain_len - 1) + .map(|i| FnRefBinding { + lhs: format!("a{i}"), + rhs: format!("a{}", i + 1), + rhs_receiver: None, + }) + .collect(); + fn_ref_bindings.push(FnRefBinding { + lhs: format!("a{}", chain_len - 1), + rhs: "handler".to_string(), + rhs_receiver: None, + }); + + let def_names: HashSet<&str> = ["handler"].into_iter().collect(); + let imported_names: HashMap<&str, &str> = HashMap::new(); + let definition_params: HashMap<&str, Vec<&str>> = HashMap::new(); + let bindings = PtsBindings { + fn_ref_bindings: &fn_ref_bindings, + param_bindings: &[], + array_elem_bindings: &[], + spread_arg_bindings: &[], + for_of_bindings: &[], + array_callback_bindings: &[], + object_rest_param_bindings: &[], + object_prop_bindings: &[], + }; + + // A cap well below the chain length must not converge for a0. + let pts_low = + build_points_to_map(&bindings, &def_names, &imported_names, &definition_params, 3); + assert!( + resolve_via_points_to("a0", &pts_low).is_empty(), + "expected a0 to NOT resolve with max_iterations=3 (chain needs {chain_len})" + ); + + // A cap at the chain length must fully converge for a0. + let pts_high = build_points_to_map( + &bindings, + &def_names, + &imported_names, + &definition_params, + chain_len, + ); + assert_eq!( + resolve_via_points_to("a0", &pts_high), + vec!["handler"], + "expected a0 to resolve to handler with max_iterations={chain_len}" + ); + } } #[cfg(test)] diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs b/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs index f600ab191..324a39c12 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs @@ -371,6 +371,49 @@ pub struct SavedReverseDepEdge { pub dynamic: i64, } +/// Key identifying a same-(name, kind) sibling group within one file. +pub type SiblingGroupKey = (String, String, String); + +/// Computes the sorted line list for every (name, kind) sibling group within +/// `file`, keyed by `(name, kind)`. +/// +/// A file can contain multiple distinct symbols with the identical name and +/// kind — e.g. several object-literal `close() {}` methods returned from +/// different functions in the same file. `(name, kind, file)` alone is not a +/// unique identity for such symbols, so `reconnect_reverse_dep_edges` cannot +/// safely tell them apart by nearest-line matching once unrelated code +/// shifts the candidates unevenly, or a same-named sibling is added/removed +/// in the same edit (#1752, #1865). The sorted line list captured here — the +/// sibling group's layout at save time — lets reconnection align old targets +/// to their correct new nodes by rank when the sibling count is unchanged, +/// or by the dominant line-shift that best explains the surviving siblings +/// when it changed (see `align_sibling_lines`), which tolerates both a +/// uniform shift of the whole group AND a change in the group's size. +fn compute_sibling_groups(conn: &Connection, file: &str) -> HashMap<(String, String), Vec> { + let mut groups: HashMap<(String, String), Vec> = HashMap::new(); + let mut stmt = match conn.prepare("SELECT name, kind, line FROM nodes WHERE file = ?1") { + Ok(s) => s, + Err(_) => return groups, + }; + let rows = match stmt.query_map([file], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) { + Ok(r) => r, + Err(_) => return groups, + }; + for row in rows.flatten() { + groups.entry((row.0, row.1)).or_default().push(row.2); + } + for lines in groups.values_mut() { + lines.sort_unstable(); + } + groups +} + /// Save edges from reverse-dep files → changed files BEFORE purge so they /// can be reconnected to new target node IDs after node insertion (#1012). /// @@ -383,10 +426,11 @@ pub struct SavedReverseDepEdge { pub fn save_reverse_dep_edges( conn: &Connection, changed_paths: &[String], -) -> Vec { +) -> (Vec, HashMap>) { let mut saved = Vec::new(); + let mut sibling_groups: HashMap> = HashMap::new(); if changed_paths.is_empty() { - return saved; + return (saved, sibling_groups); } let changed_set: HashSet<&str> = changed_paths.iter().map(|s| s.as_str()).collect(); @@ -399,10 +443,14 @@ pub fn save_reverse_dep_edges( WHERE n_tgt.file = ?1 AND n_src.file != n_tgt.file", ) { Ok(s) => s, - Err(_) => return saved, + Err(_) => return (saved, sibling_groups), }; for changed in changed_paths { + // Must be computed BEFORE this file's nodes are purged — captures the + // pre-purge sibling layout so reconnection can map old→new correctly + // even when several same-named/same-kind symbols exist in the file. + let groups = compute_sibling_groups(conn, changed); let rows = match stmt.query_map([changed], |row| { Ok(( row.get::<_, i64>(0)?, @@ -425,6 +473,13 @@ pub fn save_reverse_dep_edges( if changed_set.contains(row.8.as_str()) { continue; } + let group_key: SiblingGroupKey = (row.1.clone(), row.2.clone(), row.3.clone()); + sibling_groups.entry(group_key).or_insert_with(|| { + groups + .get(&(row.1.clone(), row.2.clone())) + .cloned() + .unwrap_or_else(|| vec![row.4]) + }); saved.push(SavedReverseDepEdge { source_id: row.0, tgt_name: row.1, @@ -437,20 +492,159 @@ pub fn save_reverse_dep_edges( }); } } - saved + (saved, sibling_groups) +} + +/// Aligns two ascending line arrays representing the same-(name, kind) +/// sibling group before (`old_lines`) and after (`new_lines`) a +/// purge+reinsert. Mirrors `alignSiblingLines` in `build-edges.ts`. +/// +/// When the sibling count is unchanged, declarations of the same name and +/// kind within one file keep their relative textual order across an edit — +/// even when the whole group shifts by an arbitrary, non-uniform amount per +/// sibling (e.g. one sibling's own body grew independently) — so mapping by +/// rank (1st old -> 1st new, 2nd -> 2nd, ...) is always correct (#1752). +/// +/// When the count changed (a same-named sibling was added or removed in the +/// same edit), rank order alone can't tell which element is the new/missing +/// one. But the untouched siblings' OWN source text wasn't edited, so they +/// all shift by the exact SAME line delta — whatever unrelated insertion or +/// deletion elsewhere in the file caused the shift applies uniformly to +/// everything below/above it. This finds the single shift value `S` that +/// makes `old + S` land on a real new line for the most siblings — the +/// dominant shift — and matches every old line whose shifted position +/// exists in `new_lines`; the rest were removed. This is far more reliable +/// than picking whichever old/new pairing merely minimizes total line +/// distance, which a uniform shift can fool once an old line coincidentally +/// ends up numerically closer to a different sibling's new position than to +/// its own (confirmed against this repo's real #1752 fixture numbers) — +/// see #1865. +/// +/// Returns a map from each old line to its aligned new line. An old line +/// missing from the result means its sibling was removed (no new line +/// matches it) — callers must drop, not guess, in that case. +fn align_sibling_lines(old_lines: &[i64], new_lines: &[i64]) -> HashMap { + let mut result = HashMap::new(); + if old_lines.is_empty() || new_lines.is_empty() { + return result; + } + + if old_lines.len() == new_lines.len() { + for (old_line, new_line) in old_lines.iter().zip(new_lines.iter()) { + result.insert(*old_line, *new_line); + } + return result; + } + + let new_line_set: HashSet = new_lines.iter().copied().collect(); + let mut shift_counts: HashMap = HashMap::new(); + for &old_line in old_lines { + for &new_line in new_lines { + *shift_counts.entry(new_line - old_line).or_insert(0) += 1; + } + } + // Tie-break fully by value (not HashMap iteration order, which Rust + // leaves unspecified): prefer the higher match count, then the smaller + // magnitude shift, then the smaller signed shift. Mirrors the JS + // implementation exactly so both engines pick the same shift on a tie. + let mut best_shift = 0i64; + let mut best_count = -1i64; + for (&shift, &count) in &shift_counts { + let better = count > best_count + || (count == best_count + && (shift.abs() < best_shift.abs() + || (shift.abs() == best_shift.abs() && shift < best_shift))); + if better { + best_shift = shift; + best_count = count; + } + } + for &old_line in old_lines { + let candidate = old_line + best_shift; + if new_line_set.contains(&candidate) { + result.insert(old_line, candidate); + } + } + result +} + +/// Picks the correct reconnect target among same-(name, kind, file) +/// candidates. +/// +/// A single candidate is an unambiguous match. With several candidates (e.g. +/// multiple object-literal `close() {}` methods in one file), the saved +/// sibling-group snapshot from before purge is aligned against the current +/// candidate lines (see `align_sibling_lines`) to find which new line the +/// saved target's old line maps to — correct even when the whole group +/// shifted and its size changed in the same edit. Falls back to +/// nearest-line only when no sibling-group snapshot is available, or the +/// group is too large to align cheaply. +fn pick_reconnect_target( + candidates: &[(i64, i64)], + tgt_line: i64, + group_key: &SiblingGroupKey, + saved_sibling_groups: &HashMap>, + alignment_cache: &mut HashMap>, + max_align_group_size: usize, +) -> Option { + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + return Some(candidates[0].0); + } + + if let Some(old_lines) = saved_sibling_groups.get(group_key) { + if !old_lines.is_empty() + && old_lines.len() <= max_align_group_size + && candidates.len() <= max_align_group_size + { + let alignment = match alignment_cache.entry(group_key.clone()) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + let new_lines: Vec = candidates.iter().map(|(_, line)| *line).collect(); + e.insert(align_sibling_lines(old_lines, &new_lines)) + } + }; + return match alignment.get(&tgt_line) { + Some(new_line) => candidates + .iter() + .find(|(_, line)| line == new_line) + .map(|(id, _)| *id), + // tgt_line's sibling was legitimately removed in this edit — + // the alignment already accounted for the size change, so + // falling through to nearest-line here would silently + // reattach to an unrelated sibling instead of correctly + // dropping this edge. + None => None, + }; + } + } + + // No sibling-group snapshot (shouldn't normally happen — every saved + // edge has one recorded at save time) or the group exceeds the + // alignment size cap — fall back to nearest-line. + candidates + .iter() + .min_by_key(|(_, line)| (line - tgt_line).abs()) + .map(|(id, _)| *id) } /// Reconnect saved reverse-dep edges to the new target node IDs. /// /// The source node ID is still valid (reverse-dep nodes were never purged). -/// The target was deleted and re-inserted with a new ID — look it up by -/// (name, kind, file) using nearest-line matching, and recreate the edge. -/// Mirrors `reconnectReverseDepEdges` in `build-edges.ts`. +/// The target was deleted and re-inserted with a new ID — look up all +/// (name, kind, file) candidates and pick the one the saved sibling-group +/// snapshot aligns to the saved line (see `pick_reconnect_target`), then +/// recreate the edge. Mirrors `reconnectReverseDepEdges` in +/// `build-edges.ts`. /// /// Returns (reconnected, dropped) counts. pub fn reconnect_reverse_dep_edges( conn: &Connection, saved: &[SavedReverseDepEdge], + saved_sibling_groups: &HashMap>, + max_align_group_size: usize, ) -> (usize, usize) { if saved.is_empty() { return (0, 0); @@ -463,9 +657,8 @@ pub fn reconnect_reverse_dep_edges( let mut reconnected = 0usize; let mut dropped = 0usize; { - let mut find_stmt = match tx.prepare( - "SELECT id FROM nodes WHERE name = ?1 AND kind = ?2 AND file = ?3 \ - ORDER BY ABS(line - ?4) LIMIT 1", + let mut candidates_stmt = match tx.prepare( + "SELECT id, line FROM nodes WHERE name = ?1 AND kind = ?2 AND file = ?3 ORDER BY line", ) { Ok(s) => s, Err(_) => return (0, 0), @@ -477,12 +670,45 @@ pub fn reconnect_reverse_dep_edges( Ok(s) => s, Err(_) => return (0, 0), }; + + // Cache candidate lists per (name, kind, file) group — many saved + // edges often share the same target (e.g. several callers of the + // same function), so this avoids re-querying per edge. + let mut candidates_cache: HashMap> = HashMap::new(); + // Cache the (potentially expensive) alignment result per group too — + // shared across every saved edge targeting the same sibling group. + let mut alignment_cache: HashMap> = HashMap::new(); + for s in saved { - match find_stmt.query_row( - rusqlite::params![&s.tgt_name, &s.tgt_kind, &s.tgt_file, s.tgt_line], - |row| row.get::<_, i64>(0), + let key = (s.tgt_name.clone(), s.tgt_kind.clone(), s.tgt_file.clone()); + let candidates = match candidates_cache.entry(key.clone()) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + let mut rows: Vec<(i64, i64)> = Vec::new(); + if let Ok(mut rows_iter) = candidates_stmt + .query(rusqlite::params![&s.tgt_name, &s.tgt_kind, &s.tgt_file]) + { + while let Ok(Some(row)) = rows_iter.next() { + if let (Ok(id), Ok(line)) = + (row.get::<_, i64>(0), row.get::<_, i64>(1)) + { + rows.push((id, line)); + } + } + } + e.insert(rows) + } + }; + + match pick_reconnect_target( + candidates, + s.tgt_line, + &key, + saved_sibling_groups, + &mut alignment_cache, + max_align_group_size, ) { - Ok(new_id) => { + Some(new_id) => { // INSERT OR IGNORE silently swallows duplicate-row constraint // errors and returns Ok(0). Only count rows that actually // inserted so the diagnostic counter isn't inflated by no-ops. @@ -498,7 +724,7 @@ pub fn reconnect_reverse_dep_edges( Err(_) => dropped += 1, } } - Err(_) => { + None => { dropped += 1; } } @@ -545,6 +771,49 @@ pub fn find_reverse_dependencies( reverse_deps } +/// Captures the forward+reverse import-neighbor file set for files about to +/// be removed, BEFORE `purge_changed_files` deletes their edges. +/// +/// `refresh_affected_directory_metrics` discovers cross-directory neighbors +/// by querying LIVE import edges from the affected directories — this works +/// for added/modified files (their edges are rebuilt and still present) but +/// not for removed files, whose edges in both directions are purged before +/// the structure stage runs. Reading them here, one step earlier in the +/// pipeline, closes that gap. Mirrors `captureRemovedFileNeighbors` in +/// `detect-changes.ts` (#1839). +pub fn capture_removed_file_neighbors(conn: &Connection, removed_files: &[String]) -> Vec { + if removed_files.is_empty() { + return Vec::new(); + } + let removed_set: HashSet<&str> = removed_files.iter().map(|s| s.as_str()).collect(); + let mut neighbors: HashSet = HashSet::new(); + + let mut stmt = match conn.prepare( + "SELECT n2.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n1.file = ?1 \ + UNION \ + SELECT n1.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file AND n2.file = ?1", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + for f in removed_files { + if let Ok(rows) = stmt.query_map([f], |row| row.get::<_, String>(0)) { + for row in rows.flatten() { + if !removed_set.contains(row.as_str()) { + neighbors.insert(row); + } + } + } + } + + neighbors.into_iter().collect() +} + /// Purge graph data for changed/removed files and delete outgoing edges for reverse deps. /// /// Deletion order: analysis dependents → edges → nodes (matches `connection::purge_files_data`). @@ -828,4 +1097,342 @@ mod tests { let removed = detect_removed_files(&existing, &all_files, "/project", None); assert_eq!(removed, vec!["src/deleted.ts"]); } + + // ── Reverse-dep edge reconnection (#1752, #1865) ──────────────────── + + /// Convenience wrapper mirroring the pre-#1865 `pick_reconnect_target` + /// call shape: builds the single-group map + a fresh alignment cache + /// internally so each test case reads as a plain (candidates, old_lines, + /// tgt_line) -> Option call. + fn pick( + candidates: &[(i64, i64)], + old_lines: &[i64], + tgt_line: i64, + ) -> Option { + let key: SiblingGroupKey = ("x".to_string(), "method".to_string(), "f.ts".to_string()); + let mut groups = HashMap::new(); + groups.insert(key.clone(), old_lines.to_vec()); + let mut cache = HashMap::new(); + pick_reconnect_target(candidates, tgt_line, &key, &groups, &mut cache, 200) + } + + #[test] + fn pick_reconnect_target_single_candidate_is_unambiguous() { + let candidates = vec![(42, 100)]; + assert_eq!(pick(&candidates, &[999], 999), Some(42)); + } + + #[test] + fn pick_reconnect_target_no_candidates_returns_none() { + let candidates: Vec<(i64, i64)> = vec![]; + assert_eq!(pick(&candidates, &[100], 100), None); + } + + #[test] + fn pick_reconnect_target_aligns_unchanged_group_by_rank() { + // Four same-named/same-kind siblings (e.g. four `close() {}` methods), + // shifted down by an insertion elsewhere in the file. The 3rd-ranked + // sibling (old line 500) must still resolve to the 3rd-ranked sibling + // post-shift (id 30, line 580), NOT to whichever candidate is nearest + // to the stale old line 500 (which would wrongly pick a different id + // once the group shifts unevenly). + let old_lines = vec![433, 461, 500, 580]; + let candidates = vec![(10, 178), (20, 461), (30, 500 + 80), (40, 580 + 80)]; + let picked = pick(&candidates, &old_lines, 500); + assert_eq!(picked, Some(30)); + } + + #[test] + fn pick_reconnect_target_drops_edge_when_its_own_sibling_was_removed() { + // Sibling count changed (4 -> 3): old line 200's sibling was removed, + // the other three shifted down by 50. The edge targeting line 200 + // must be dropped (no matching new line) — not silently reattached + // to whichever candidate happens to be nearest. + let old_lines = vec![100, 200, 300, 400]; + let candidates = vec![(10, 150), (30, 350), (40, 450)]; // id 20 (line 200) removed + assert_eq!(pick(&candidates, &old_lines, 200), None); + // The untouched siblings must still resolve correctly despite the + // group's size having changed in the same edit (#1865). + assert_eq!(pick(&candidates, &old_lines, 100), Some(10)); + assert_eq!(pick(&candidates, &old_lines, 300), Some(30)); + assert_eq!(pick(&candidates, &old_lines, 400), Some(40)); + } + + #[test] + fn pick_reconnect_target_survives_added_sibling_plus_shift() { + // Sibling count changed (3 -> 4): a new sibling was inserted between + // the 1st and 2nd old siblings, and the whole group shifted down by + // 50. The two original siblings must still resolve to their own new + // lines, not to the newly inserted one. + let old_lines = vec![100, 300, 400]; + let candidates = vec![(10, 150), (99, 250), (30, 350), (40, 450)]; // id 99 is new + assert_eq!(pick(&candidates, &old_lines, 100), Some(10)); + assert_eq!(pick(&candidates, &old_lines, 300), Some(30)); + assert_eq!(pick(&candidates, &old_lines, 400), Some(40)); + } + + #[test] + fn align_sibling_lines_forces_exact_pairing_when_counts_match() { + let old_lines = vec![433, 461, 500, 580]; + let new_lines = vec![513, 541, 580, 660]; // uniform +80 shift + let alignment = align_sibling_lines(&old_lines, &new_lines); + assert_eq!(alignment.get(&433), Some(&513)); + assert_eq!(alignment.get(&461), Some(&541)); + assert_eq!(alignment.get(&500), Some(&580)); + assert_eq!(alignment.get(&580), Some(&660)); + } + + #[test] + fn align_sibling_lines_empty_inputs_yield_empty_map() { + assert!(align_sibling_lines(&[], &[1, 2, 3]).is_empty()); + assert!(align_sibling_lines(&[1, 2, 3], &[]).is_empty()); + assert!(align_sibling_lines(&[], &[]).is_empty()); + } + + /// Minimal in-memory schema covering only the columns `save_reverse_dep_edges` + /// / `reconnect_reverse_dep_edges` touch — not the full production migration set. + fn test_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER, + UNIQUE(name, kind, file, line) + ); + CREATE TABLE edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + kind TEXT NOT NULL, + confidence REAL DEFAULT 1.0, + dynamic INTEGER DEFAULT 0 + );", + ) + .unwrap(); + conn + } + + fn insert_node(conn: &Connection, name: &str, kind: &str, file: &str, line: i64) -> i64 { + conn.execute( + "INSERT INTO nodes (name, kind, file, line) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![name, kind, file, line], + ) + .unwrap(); + conn.last_insert_rowid() + } + + #[test] + fn compute_sibling_groups_ranks_same_name_kind_siblings_by_line() { + let conn = test_conn(); + insert_node(&conn, "close", "method", "src/db/connection.ts", 433); + insert_node(&conn, "close", "method", "src/db/connection.ts", 461); + insert_node(&conn, "close", "method", "src/db/connection.ts", 500); + insert_node(&conn, "close", "method", "src/db/connection.ts", 580); + // An unrelated, uniquely-named sibling must not pollute the group. + insert_node(&conn, "openDb", "function", "src/db/connection.ts", 161); + + let groups = compute_sibling_groups(&conn, "src/db/connection.ts"); + assert_eq!( + groups.get(&("close".to_string(), "method".to_string())), + Some(&vec![433, 461, 500, 580]) + ); + assert_eq!( + groups.get(&("openDb".to_string(), "function".to_string())), + Some(&vec![161]) + ); + } + + /// End-to-end reproduction of #1752: a reverse-dep caller's edge to the + /// 3rd of four same-named `close` siblings must survive a purge+reinsert + /// cycle that shifts all four down uniformly (inserting a new, unrelated + /// function above them — exactly the real repro: "insert N lines above + /// several existing functions"). A uniform shift is already enough to + /// break the old nearest-*old*-line heuristic: once the whole group moves + /// far enough, the saved reference line (500) ends up numerically closest + /// to the wrong (lowest) candidate's new line rather than its own — + /// mirroring the real bug's `close@433` vs `close@580` divergence found + /// by replaying this repo's actual commit history. + #[test] + fn reconnect_survives_uniform_shift_of_same_named_siblings() { + let conn = test_conn(); + let file = "src/db/connection.ts"; + + // Pre-edit layout: four `close` siblings. + insert_node(&conn, "close", "method", file, 433); + insert_node(&conn, "close", "method", file, 461); + let target_old_id = insert_node(&conn, "close", "method", file, 500); + insert_node(&conn, "close", "method", file, 580); + // External caller in a different (untouched) file. + let caller_id = insert_node(&conn, "triageData", "function", "src/features/triage.ts", 146); + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'calls', 0.5, 0)", + rusqlite::params![caller_id, target_old_id], + ) + .unwrap(); + + let (saved, sibling_groups) = save_reverse_dep_edges(&conn, &[file.to_string()]); + assert_eq!(saved.len(), 1); + assert_eq!( + sibling_groups.get(&("close".to_string(), "method".to_string(), file.to_string())), + Some(&vec![433, 461, 500, 580]) + ); + + // Simulate purge_changed_files: delete the changed file's nodes/edges. + conn.execute("DELETE FROM edges WHERE target_id IN (SELECT id FROM nodes WHERE file = ?1)", [file]).unwrap(); + conn.execute("DELETE FROM nodes WHERE file = ?1", [file]).unwrap(); + + // Re-insert: a new function was added above all four, shifting every + // sibling down by the same delta (147 lines) — the exact shape of the + // real #1752 repro (insert one helper above several functions). + insert_node(&conn, "close", "method", file, 433 + 147); + insert_node(&conn, "close", "method", file, 461 + 147); + let target_new_id = insert_node(&conn, "close", "method", file, 500 + 147); + insert_node(&conn, "close", "method", file, 580 + 147); + + let (reconnected, dropped) = + reconnect_reverse_dep_edges(&conn, &saved, &sibling_groups, 200); + assert_eq!((reconnected, dropped), (1, 0)); + + let new_target: i64 = conn + .query_row( + "SELECT target_id FROM edges WHERE source_id = ?1", + [caller_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(new_target, target_new_id); + } + + /// End-to-end reproduction of #1865: same shift as #1752's repro, but a + /// same-named/same-kind sibling is ALSO renamed away (removed from the + /// group) in the same edit — the exact compound scenario the pre-#1865 + /// ordinal+nearest-line fallback could not resolve correctly. The three + /// untouched siblings' reverse-dep edges must all reconnect to their own + /// correct new node despite the group shrinking from 4 to 3 in the same + /// build that also shifted it. + #[test] + fn reconnect_survives_shift_plus_sibling_removed_in_same_edit() { + let conn = test_conn(); + let file = "src/db/connection.ts"; + + // Pre-edit layout: four `close` siblings (A, B, C, D by old line). + let a_old = insert_node(&conn, "close", "method", file, 433); + let b_old = insert_node(&conn, "close", "method", file, 461); + let c_old = insert_node(&conn, "close", "method", file, 500); + let d_old = insert_node(&conn, "close", "method", file, 580); + + // One external caller per sibling, in untouched files. + let caller_a = insert_node(&conn, "useA", "function", "src/features/a.ts", 10); + let caller_b = insert_node(&conn, "useB", "function", "src/features/b.ts", 10); + let caller_c = insert_node(&conn, "useC", "function", "src/features/c.ts", 10); + let caller_d = insert_node(&conn, "useD", "function", "src/features/d.ts", 10); + for (caller, target) in [ + (caller_a, a_old), + (caller_b, b_old), + (caller_c, c_old), + (caller_d, d_old), + ] { + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'calls', 0.9, 0)", + rusqlite::params![caller, target], + ) + .unwrap(); + } + + let (saved, sibling_groups) = save_reverse_dep_edges(&conn, &[file.to_string()]); + assert_eq!(saved.len(), 4); + + // Simulate purge_changed_files. + conn.execute("DELETE FROM edges WHERE target_id IN (SELECT id FROM nodes WHERE file = ?1)", [file]).unwrap(); + conn.execute("DELETE FROM nodes WHERE file = ?1", [file]).unwrap(); + + // Re-insert: whole group shifted +147 (unrelated helper inserted + // above), AND B's `close` was renamed to `shutdown` in the same + // edit — sibling count for (close, method) drops from 4 to 3. + let a_new = insert_node(&conn, "close", "method", file, 433 + 147); + insert_node(&conn, "shutdown", "method", file, 461 + 147); // was B's close + let c_new = insert_node(&conn, "close", "method", file, 500 + 147); + let d_new = insert_node(&conn, "close", "method", file, 580 + 147); + + let (reconnected, dropped) = + reconnect_reverse_dep_edges(&conn, &saved, &sibling_groups, 200); + // A, C, D reconnect correctly; B's edge is dropped (its `close` no + // longer exists — renamed to `shutdown`). + assert_eq!((reconnected, dropped), (3, 1)); + + let target_of = |caller: i64| -> Option { + conn.query_row( + "SELECT target_id FROM edges WHERE source_id = ?1", + [caller], + |row| row.get(0), + ) + .ok() + }; + assert_eq!(target_of(caller_a), Some(a_new)); + assert_eq!(target_of(caller_b), None); + assert_eq!(target_of(caller_c), Some(c_new)); + assert_eq!(target_of(caller_d), Some(d_new)); + } + + // ── capture_removed_file_neighbors (#1839) ────────────────────────── + + #[test] + fn capture_removed_file_neighbors_finds_both_directions() { + let conn = test_conn(); + // src/pkgA/a1.js imports src/pkgB/b1.js (forward); src/pkgC/c1.js + // imports src/pkgA/a1.js (reverse) — both directions must surface. + let a1 = insert_node(&conn, "a1", "function", "src/pkgA/a1.js", 1); + let b1 = insert_node(&conn, "b1", "function", "src/pkgB/b1.js", 1); + let c1 = insert_node(&conn, "c1", "function", "src/pkgC/c1.js", 1); + // Unrelated file — must not appear in the result. + insert_node(&conn, "d1", "function", "src/pkgD/d1.js", 1); + + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)", + rusqlite::params![a1, b1], + ) + .unwrap(); + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)", + rusqlite::params![c1, a1], + ) + .unwrap(); + + let mut neighbors = + capture_removed_file_neighbors(&conn, &["src/pkgA/a1.js".to_string()]); + neighbors.sort(); + assert_eq!( + neighbors, + vec!["src/pkgB/b1.js".to_string(), "src/pkgC/c1.js".to_string()] + ); + } + + #[test] + fn capture_removed_file_neighbors_empty_for_no_removed_files() { + let conn = test_conn(); + assert!(capture_removed_file_neighbors(&conn, &[]).is_empty()); + } + + #[test] + fn capture_removed_file_neighbors_excludes_other_removed_files() { + // Two files being removed together that import each other must not + // report each other as "neighbors" — only still-live files count. + let conn = test_conn(); + let a1 = insert_node(&conn, "a1", "function", "src/pkgA/a1.js", 1); + let a2 = insert_node(&conn, "a2", "function", "src/pkgA/a2.js", 1); + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'imports', 1.0, 0)", + rusqlite::params![a1, a2], + ) + .unwrap(); + + let neighbors = capture_removed_file_neighbors( + &conn, + &["src/pkgA/a1.js".to_string(), "src/pkgA/a2.js".to_string()], + ); + assert!(neighbors.is_empty()); + } } diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index fd09d4a2f..0a5ae03ec 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -6,9 +6,10 @@ use crate::domain::graph::builder::barrel_resolution::{self, BarrelContext, ReexportRef}; use crate::domain::graph::resolve; -use crate::types::{FileSymbols, PathAliases}; +use crate::shared::constants::{is_type_erased_import_target, TYPESCRIPT_EXTENSIONS}; +use crate::types::{FileSymbols, PathAliases, RenamedImport}; use rusqlite::Connection; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; /// A resolved reexport entry for a barrel file. @@ -17,6 +18,9 @@ pub struct ReexportEntry { pub source: String, pub names: Vec, pub wildcard_reexport: bool, + /// `{ local, imported }` pairs for `export { X as Y } from …` specifiers + /// within this entry — see `barrel_resolution::ReexportRef::renames` (#1823). + pub renames: Vec, } /// Context for import edge building — holds resolved imports, reexport map, and file symbols. @@ -28,13 +32,15 @@ pub struct ImportEdgeContext { /// Set of files that are barrel-only (reexport count >= definition count). pub barrel_only_files: HashSet, /// Parsed symbols per relative path. - pub file_symbols: HashMap, + pub file_symbols: BTreeMap, /// Root directory. pub root_dir: String, /// Path aliases. pub aliases: PathAliases, /// All known file paths (for resolution). pub known_files: HashSet, + /// Monorepo workspace packages, keyed by package name (issue #1927). + pub workspaces: HashMap, } /// An edge to insert into the database. @@ -61,6 +67,7 @@ impl ImportEdgeContext { import_source, &self.root_dir, &self.aliases, + Some(&self.workspaces), ) } @@ -89,7 +96,7 @@ impl ImportEdgeContext { barrel_path: &str, symbol_name: &str, visited: &mut HashSet, - ) -> Option { + ) -> Option { barrel_resolution::resolve_barrel_export(self, barrel_path, symbol_name, visited) } } @@ -103,6 +110,7 @@ impl BarrelContext for ImportEdgeContext { source: re.source.as_str(), names: &re.names, wildcard_reexport: re.wildcard_reexport, + renames: &re.renames, }) .collect() }) @@ -115,6 +123,78 @@ impl BarrelContext for ImportEdgeContext { } } +/// Adapter over the two distinct "import statement" Rust representations in +/// this crate that [`import_name_pairs`] needs to read from: the native +/// orchestrator's own parsed `crate::types::Import`, and the FFI-facing +/// `ImportInfo` wire struct the hybrid native path (`build_edges.rs`) +/// deserializes from JS. Both mirror TS `Import` — this trait exists only so +/// `import_name_pairs` has one implementation instead of being duplicated +/// per Rust type, since the two structs differ in `Option` wrapping. +pub(crate) trait ImportNameSource { + fn names(&self) -> &[String]; + fn renamed_imports(&self) -> &[RenamedImport]; + fn is_type_only(&self) -> bool; + fn type_only_names(&self) -> &[String]; +} + +impl ImportNameSource for crate::types::Import { + fn names(&self) -> &[String] { + &self.names + } + fn renamed_imports(&self) -> &[RenamedImport] { + self.renamed_imports.as_deref().unwrap_or(&[]) + } + fn is_type_only(&self) -> bool { + self.type_only.unwrap_or(false) + } + fn type_only_names(&self) -> &[String] { + self.type_only_names.as_deref().unwrap_or(&[]) + } +} + +/// Pairs each locally-bound name from an import statement with its original +/// (pre-rename) exported name — identical to the local name unless the +/// specifier renames a binding (`import { X as Y }`). Barrel tracing and +/// target-file symbol lookups must search using the *original* name — the +/// renamed local alias only exists in the importing file, not in the file +/// being imported from (#1730). Mirrors `importNamePairs` in build-edges.ts. +/// +/// Also reports, per name, whether it should be treated as type-only — +/// either because the whole statement is (`import type { X }`) or because +/// this specific specifier carries the inline modifier +/// (`import { type X }`, #1813). +pub(crate) fn import_name_pairs(imp: &T) -> Vec<(String, String, bool)> { + let mut original_name_for: HashMap<&str, &str> = HashMap::new(); + for r in imp.renamed_imports() { + original_name_for.insert(&r.local, &r.imported); + } + let statement_type_only = imp.is_type_only(); + let type_only_names: HashSet<&str> = imp.type_only_names().iter().map(|s| s.as_str()).collect(); + imp.names() + .iter() + .map(|name| { + let local = name + .strip_prefix("* as ") + .or_else(|| name.strip_prefix("*\tas ")) + .unwrap_or(name); + let original = original_name_for.get(local).copied().unwrap_or(local); + let type_only = statement_type_only || type_only_names.contains(local); + (local.to_string(), original.to_string(), type_only) + }) + .collect() +} + +/// True when an import carries any type-only signal — a whole-statement +/// `import type { X }` or at least one inline per-specifier `type` modifier +/// (`import { type X }`, #1813). +fn has_type_only_names(imp: &crate::types::Import) -> bool { + imp.type_only.unwrap_or(false) + || imp + .type_only_names + .as_ref() + .is_some_and(|names| !names.is_empty()) +} + /// Build the reexport map from parsed file symbols. pub fn build_reexport_map(ctx: &ImportEdgeContext) -> HashMap> { let mut reexport_map = HashMap::new(); @@ -134,6 +214,7 @@ pub fn build_reexport_map(ctx: &ImportEdgeContext) -> HashMap HashMap= definition count). -pub fn detect_barrel_only_files(ctx: &ImportEdgeContext) -> HashSet { - let mut barrel_only = HashSet::new(); - for rel_path in ctx.file_symbols.keys() { - if ctx.is_barrel_file(rel_path) { - barrel_only.insert(rel_path.clone()); - } - } - barrel_only +/// Detect which of `candidate_paths` are barrel-only (reexport count >= +/// definition count). +/// +/// `candidate_paths` must be scoped to files loaded purely to resolve +/// reexport chains (the transient merges `reparse_barrel_candidates` performs +/// during Stage 6b) — never to every key in `ctx.file_symbols`. A file that's +/// genuinely part of this build's changed set (or, on a full build, *every* +/// file) must always get its own non-reexport imports emitted; only a file +/// that was side-loaded solely for barrel resolution — whose real edges +/// either don't exist yet or are being fully reconstructed by this same +/// re-parse — is eligible to have its non-reexport imports skipped. Mirrors +/// `resolve-imports.ts::reparseBarrelFiles`, which marks barrel-only status +/// inside its own re-parse loop rather than recomputing it over the whole +/// `fileSymbols` map (#1848). +pub fn detect_barrel_only_files( + ctx: &ImportEdgeContext, + candidate_paths: &[String], +) -> HashSet { + candidate_paths + .iter() + .filter(|rel_path| ctx.is_barrel_file(rel_path)) + .cloned() + .collect() } /// Load every file node ID into a HashMap in one query — replaces per-import @@ -178,20 +273,23 @@ fn load_file_node_ids(conn: &Connection) -> HashMap { map } -/// Load symbol node IDs for the supplied `(name, file)` pairs in one chunked -/// query. Mirrors the JS `nodesByNameAndFile` lookup map; preserves the -/// first-row semantics of the legacy `LIMIT 1` query by keeping the first ID -/// seen per key. +/// Load symbol node IDs (and kinds) for the supplied `(name, file)` pairs in +/// one chunked query. Mirrors the JS `nodesByNameAndFile` lookup map; +/// preserves the first-row semantics of the legacy `LIMIT 1` query by keeping +/// the first row seen per key. `kind` lets `emit_named_symbol_rows` credit +/// plain imports resolving to a TypeScript interface/type-alias declaration, +/// not just `import type` statements (#1833). /// -/// The pairs are pre-computed by walking the type-only imports in -/// `ctx.file_symbols`, so we never scan the entire `nodes` table — even on -/// monorepos with 100k+ symbols, only the small slice actually referenced by -/// type-only imports is hit (#1013, #1028 review). +/// The pairs are pre-computed by walking the type-only imports, named +/// re-exports, and TypeScript-file-targeting imports in `ctx.file_symbols`, +/// so we never scan the entire `nodes` table — even on monorepos with 100k+ +/// symbols, only the slice actually reachable by one of those import shapes +/// is hit (#1013, #1028 review). fn load_symbol_node_ids( conn: &Connection, needed_pairs: &HashSet<(String, String)>, -) -> HashMap<(String, String), i64> { - let mut map: HashMap<(String, String), i64> = HashMap::new(); +) -> HashMap<(String, String), (i64, String)> { + let mut map: HashMap<(String, String), (i64, String)> = HashMap::new(); if needed_pairs.is_empty() { return map; } @@ -209,7 +307,7 @@ fn load_symbol_node_ids( }) .collect(); let sql = format!( - "SELECT name, file, id FROM nodes WHERE kind != 'file' AND (name, file) IN ({})", + "SELECT name, file, id, kind FROM nodes WHERE kind != 'file' AND (name, file) IN ({})", placeholders.join(",") ); let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() * 2); @@ -224,10 +322,11 @@ fn load_symbol_node_ids( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, )) }) { for r in rows.flatten() { - map.entry((r.0, r.1)).or_insert(r.2); + map.entry((r.0, r.1)).or_insert((r.2, r.3)); } } } @@ -235,32 +334,59 @@ fn load_symbol_node_ids( map } -/// Walk type-only imports in `ctx.file_symbols` and return the distinct -/// `(name, file)` pairs that `build_import_edges` will need to look up. -/// Resolves barrel files the same way the edge-building loop does so the -/// pre-computed set matches the actual lookup keys. -fn collect_type_only_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, String)> { +/// True for a named (non-wildcard) re-export — `export { X } from 'Y'` or +/// `export { X as Z } from 'Y'`. Wildcard re-exports (`export * from 'Y'`) +/// carry no specific names, so they're excluded here and handled instead by +/// the file-level `reexports` edge + the query layer's full-export fallback. +fn is_named_reexport(imp: &crate::types::Import) -> bool { + imp.reexport.unwrap_or(false) && !imp.wildcard_reexport.unwrap_or(false) +} + +/// True when `file`'s extension means it *might* hold a TypeScript +/// interface/type-alias declaration (see `is_type_erased_import_target`) — +/// used to widen `collect_symbol_lookup_pairs` beyond syntactically +/// type-only imports without scanning every plain import in every language +/// (#1833). +fn maybe_type_erased_file(file: &str) -> bool { + TYPESCRIPT_EXTENSIONS.iter().any(|ext| file.ends_with(ext)) +} + +/// Walk type-only imports, named re-exports, and plain imports that might +/// target a TypeScript interface/type-alias declaration in `ctx.file_symbols`, +/// returning the distinct `(name, file)` pairs that `build_import_edges` will +/// need to look up. Resolves barrel files the same way the edge-building +/// loop does so the pre-computed set matches the actual lookup keys. +/// Shared by symbol-level `imports-type` (#1724, #1833) and `reexports` +/// (#1742) edges — all three name specific symbols requiring a +/// (name, file) → node-id/kind lookup. +fn collect_symbol_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, String)> { let mut pairs = HashSet::new(); for (rel_path, symbols) in &ctx.file_symbols { let abs_file = Path::new(&ctx.root_dir).join(rel_path); let abs_str = abs_file.to_str().unwrap_or(""); for imp in &symbols.imports { - if !imp.type_only.unwrap_or(false) { + let is_reexport = is_named_reexport(imp); + let resolved_path = ctx.get_resolved(abs_str, &imp.source); + let maybe_type_erased = maybe_type_erased_file(&resolved_path); + if !has_type_only_names(imp) && !is_reexport && !maybe_type_erased { continue; } - let resolved_path = ctx.get_resolved(abs_str, &imp.source); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original, type_only) in import_name_pairs(imp) { + if !is_reexport && !type_only && !maybe_type_erased { + continue; + } let mut target_file = resolved_path.clone(); + let mut target_name = original; if ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - ctx.resolve_barrel_export(&resolved_path, clean_name, &mut visited) + if let Some(resolved) = + ctx.resolve_barrel_export(&resolved_path, &target_name, &mut visited) { - target_file = actual; + target_file = resolved.file; + target_name = resolved.name; } } - pairs.insert((clean_name.to_string(), target_file)); + pairs.insert((target_name, target_file)); } } } @@ -290,39 +416,59 @@ fn classify_import_kind(imp: &crate::types::Import) -> &'static str { } } -/// For a `type` import, emit one symbol-level `imports-type` edge per name -/// so the target symbols receive fan-in credit and aren't classified dead. -fn emit_type_only_symbol_rows( +/// For a `type` import, a plain import of a TypeScript interface/type-alias, +/// or a named re-export, emit one symbol-level edge per name so the target +/// symbols receive fan-in credit and aren't classified dead (`imports-type`, +/// #1724, #1833), or so `codegraph exports` can report the precise re-export +/// surface instead of the target's full export list (`reexports`, #1742). +/// `kind` selects which edge kind to emit. +/// +/// For `kind == "imports-type"`, a specifier gets an edge when either it's +/// actually marked type-only (whole-statement or inline per-specifier, +/// #1813 — a mixed `import { value, type Foo }` must not credit `value` on +/// this basis alone), or the resolved target is a TypeScript +/// interface/type-alias declaration (`is_type_erased_import_target`) — those +/// kinds are erased before runtime, so a plain `import { Foo } from 'y'` (no +/// `type` keyword) is the only consumption signal `codegraph exports` can +/// observe for them (#1833). +fn emit_named_symbol_rows( edges: &mut Vec, file_node_id: i64, imp: &crate::types::Import, resolved_path: &str, + kind: &str, ctx: &ImportEdgeContext, - symbol_node_ids: &HashMap<(String, String), i64>, + symbol_node_ids: &HashMap<(String, String), (i64, String)>, ) { - if !imp.type_only.unwrap_or(false) { - return; - } - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original, type_only) in import_name_pairs(imp) { let mut target_file = resolved_path.to_string(); + let mut target_name = original; if ctx.is_barrel_file(resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - ctx.resolve_barrel_export(resolved_path, clean_name, &mut visited) + if let Some(resolved) = + ctx.resolve_barrel_export(resolved_path, &target_name, &mut visited) { - target_file = actual; + target_file = resolved.file; + target_name = resolved.name; } } - if let Some(&sym_id) = symbol_node_ids.get(&(clean_name.to_string(), target_file)) { - edges.push(EdgeRow { - source_id: file_node_id, - target_id: sym_id, - kind: "imports-type".to_string(), - confidence: 1.0, - dynamic: 0, - }); + let Some((sym_id, sym_kind)) = symbol_node_ids.get(&(target_name, target_file.clone())) + else { + continue; + }; + if kind == "imports-type" + && !type_only + && !is_type_erased_import_target(sym_kind, &target_file) + { + continue; } + edges.push(EdgeRow { + source_id: file_node_id, + target_id: *sym_id, + kind: kind.to_string(), + confidence: 1.0, + dynamic: 0, + }); } } @@ -347,14 +493,13 @@ fn emit_barrel_through_rows( _ => "imports", }; let mut resolved_sources: HashSet = HashSet::new(); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original, _type_only) in import_name_pairs(imp) { let mut visited = HashSet::new(); - let actual_source = - match ctx.resolve_barrel_export(resolved_path, clean_name, &mut visited) { - Some(s) => s, - None => continue, - }; + let actual_source = match ctx.resolve_barrel_export(resolved_path, &original, &mut visited) + { + Some(resolved) => resolved.file, + None => continue, + }; if actual_source == resolved_path || !resolved_sources.insert(actual_source.clone()) { continue; } @@ -379,7 +524,7 @@ fn emit_edges_for_import( is_barrel_only: bool, ctx: &ImportEdgeContext, file_node_ids: &HashMap, - symbol_node_ids: &HashMap<(String, String), i64>, + symbol_node_ids: &HashMap<(String, String), (i64, String)>, ) { let is_reexport = imp.reexport.unwrap_or(false); if is_barrel_only && !is_reexport { @@ -398,7 +543,31 @@ fn emit_edges_for_import( confidence: 1.0, dynamic: 0, }); - emit_type_only_symbol_rows(edges, file_node_id, imp, &resolved_path, ctx, symbol_node_ids); + // Always attempted (not just for `import type`/inline-`type` specifiers) — + // emit_named_symbol_rows also credits plain specifiers that resolve to a + // TypeScript interface/type-alias declaration (#1833). + if !is_reexport { + emit_named_symbol_rows( + edges, + file_node_id, + imp, + &resolved_path, + "imports-type", + ctx, + symbol_node_ids, + ); + } + if is_named_reexport(imp) { + emit_named_symbol_rows( + edges, + file_node_id, + imp, + &resolved_path, + "reexports", + ctx, + symbol_node_ids, + ); + } emit_barrel_through_rows( edges, file_node_id, @@ -414,7 +583,7 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec Result<(), String> { if edges.is_empty() { - return; + return Ok(()); } - let tx = match conn.unchecked_transaction() { - Ok(tx) => tx, - Err(e) => { - eprintln!("[codegraph] insert_edges: failed to start transaction: {e}"); - return; - } - }; + let tx = conn.unchecked_transaction().map_err(|e| { + let msg = format!("insert_edges: failed to start transaction: {e}"); + eprintln!("[codegraph] {msg}"); + msg + })?; + let mut total_chunks = 0usize; + let mut chunk_failures: Vec = Vec::new(); for chunk in edges.chunks(INSERT_CHUNK) { + total_chunks += 1; if let Err(e) = insert_edge_chunk(&tx, chunk) { - eprintln!( - "[codegraph] insert_edges: skipped chunk of {} rows due to error: {e}", - chunk.len() - ); + let msg = format!("chunk of {} row(s): {e}", chunk.len()); + eprintln!("[codegraph] insert_edges: skipped {msg}"); + chunk_failures.push(msg); } } - if let Err(e) = tx.commit() { - eprintln!("[codegraph] insert_edges: commit failed: {e}"); + + tx.commit().map_err(|e| { + let msg = format!("insert_edges: commit failed: {e}"); + eprintln!("[codegraph] {msg}"); + msg + })?; + + if !chunk_failures.is_empty() { + return Err(format!( + "insert_edges: {} of {total_chunks} chunk(s) failed to insert: {}", + chunk_failures.len(), + chunk_failures.join("; ") + )); } + Ok(()) } /// Bind and execute a single chunk in its own fallible scope so the caller @@ -535,6 +721,21 @@ mod tests { use crate::types::{Definition, Import}; fn make_symbols(defs: Vec<&str>, reexport_imports: Vec<&str>) -> FileSymbols { + make_symbols_with_imports( + defs, + reexport_imports + .into_iter() + .map(|src| { + let mut imp = Import::new(src.to_string(), vec![], 1); + imp.reexport = Some(true); + imp.wildcard_reexport = Some(true); + imp + }) + .collect(), + ) + } + + fn make_symbols_with_imports(defs: Vec<&str>, imports: Vec) -> FileSymbols { FileSymbols { file: "test.ts".to_string(), definitions: defs @@ -548,17 +749,10 @@ mod tests { complexity: None, cfg: None, children: None, + bodyless: None, }) .collect(), - imports: reexport_imports - .into_iter() - .map(|src| { - let mut imp = Import::new(src.to_string(), vec![], 1); - imp.reexport = Some(true); - imp.wildcard_reexport = Some(true); - imp - }) - .collect(), + imports, calls: vec![], classes: vec![], exports: vec![], @@ -582,7 +776,7 @@ mod tests { #[test] fn barrel_detection() { - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); // 1 def, 2 reexports → barrel file_symbols.insert( "src/index.ts".to_string(), @@ -605,10 +799,339 @@ mod tests { paths: vec![], }, known_files: HashSet::new(), + workspaces: HashMap::new(), }; assert!(ctx.is_barrel_file("src/index.ts")); assert!(!ctx.is_barrel_file("src/utils.ts")); assert!(!ctx.is_barrel_file("nonexistent.ts")); } + + /// Regression test for #1848: `detect_barrel_only_files` must only + /// classify files present in the supplied candidate list, even when a + /// file outside that list also satisfies the reexports>=ownDefs + /// heuristic. `run_pipeline` relies on this scoping to keep a + /// genuinely-changed (or, on a full build, every) file's own + /// non-reexport imports from ever being dropped — only files + /// transiently side-loaded by `reparse_barrel_candidates` for barrel + /// resolution may be classified as barrel-only. + #[test] + fn detect_barrel_only_files_scopes_to_candidate_paths_only() { + let mut file_symbols = BTreeMap::new(); + // Barrel-like (1 def, 2 reexports) but NOT in the candidate list — + // e.g. a file that's genuinely part of this build's changed set. + file_symbols.insert( + "src/changed-barrel.ts".to_string(), + make_symbols(vec!["helper"], vec!["./a", "./b"]), + ); + // Barrel-like AND in the candidate list — a file side-loaded purely + // for reexport-chain resolution. + file_symbols.insert( + "src/transient-barrel.ts".to_string(), + make_symbols(vec!["helper"], vec!["./c", "./d"]), + ); + // Not barrel-like at all, also in the candidate list. + file_symbols.insert( + "src/transient-hybrid.ts".to_string(), + make_symbols(vec!["foo", "bar", "baz"], vec!["./e"]), + ); + + let ctx = ImportEdgeContext { + batch_resolved: HashMap::new(), + reexport_map: HashMap::new(), + barrel_only_files: HashSet::new(), + file_symbols, + root_dir: "/project".to_string(), + aliases: PathAliases { + base_url: None, + paths: vec![], + }, + known_files: HashSet::new(), + workspaces: HashMap::new(), + }; + + let candidates = vec![ + "src/transient-barrel.ts".to_string(), + "src/transient-hybrid.ts".to_string(), + ]; + let barrel_only = detect_barrel_only_files(&ctx, &candidates); + + assert!(barrel_only.contains("src/transient-barrel.ts")); + assert!(!barrel_only.contains("src/transient-hybrid.ts")); + assert!( + !barrel_only.contains("src/changed-barrel.ts"), + "a barrel-like file outside the candidate list must never be classified barrel-only" + ); + } + + /// Regression test for #1848: on a full build the caller passes an empty + /// candidate list (mirrors `run_pipeline` never invoking + /// `reparse_barrel_candidates` when `is_full_build` is true), so no file + /// — however barrel-like — is ever classified barrel-only. + #[test] + fn detect_barrel_only_files_returns_empty_for_empty_candidates() { + let mut file_symbols = BTreeMap::new(); + file_symbols.insert( + "src/index.ts".to_string(), + make_symbols(vec!["helper"], vec!["./a", "./b"]), + ); + let ctx = ImportEdgeContext { + batch_resolved: HashMap::new(), + reexport_map: HashMap::new(), + barrel_only_files: HashSet::new(), + file_symbols, + root_dir: "/project".to_string(), + aliases: PathAliases { + base_url: None, + paths: vec![], + }, + known_files: HashSet::new(), + workspaces: HashMap::new(), + }; + + assert!(detect_barrel_only_files(&ctx, &[]).is_empty()); + } + + #[test] + fn import_name_pairs_flags_inline_per_specifier_type_only_names() { + // `import { value, type Foo } from './mixed'` — only `Foo` carries the + // inline modifier, so only its pair should report `type_only = true` (#1813). + let mut imp = Import::new( + "./mixed".to_string(), + vec!["value".to_string(), "Foo".to_string()], + 1, + ); + imp.type_only_names = Some(vec!["Foo".to_string()]); + + let pairs = import_name_pairs(&imp); + assert_eq!( + pairs, + vec![ + ("value".to_string(), "value".to_string(), false), + ("Foo".to_string(), "Foo".to_string(), true), + ] + ); + } + + #[test] + fn import_name_pairs_whole_statement_type_only_flags_all_names() { + // `import type { A, B } from './types'` — every name is type-only, + // regardless of `type_only_names` (which stays unset for this form). + let mut imp = Import::new( + "./types".to_string(), + vec!["A".to_string(), "B".to_string()], + 1, + ); + imp.type_only = Some(true); + + let pairs = import_name_pairs(&imp); + assert!(pairs.iter().all(|(_, _, type_only)| *type_only)); + } + + #[test] + fn import_name_pairs_plain_value_import_flags_no_names() { + let imp = Import::new( + "./utils".to_string(), + vec!["helper".to_string()], + 1, + ); + + let pairs = import_name_pairs(&imp); + assert!(pairs.iter().all(|(_, _, type_only)| !*type_only)); + } + + fn empty_ctx(file_symbols: BTreeMap) -> ImportEdgeContext { + ImportEdgeContext { + batch_resolved: HashMap::new(), + reexport_map: HashMap::new(), + barrel_only_files: HashSet::new(), + file_symbols, + root_dir: "/project".to_string(), + aliases: PathAliases { base_url: None, paths: vec![] }, + known_files: HashSet::new(), + workspaces: HashMap::new(), + } + } + + #[test] + fn emit_named_symbol_rows_credits_plain_import_of_ts_interface() { + // `import { Foo } from './types'` — no `type` keyword — where `Foo` + // is a TypeScript interface. Interfaces are erased before runtime, so + // this plain import is the only observable consumption signal + // `codegraph exports` can rely on; it must be credited exactly like + // `import type { Foo }` would be (#1833). + let ctx = empty_ctx(BTreeMap::new()); + let imp = Import::new("./types".to_string(), vec!["Foo".to_string()], 1); + let mut symbol_node_ids: HashMap<(String, String), (i64, String)> = HashMap::new(); + symbol_node_ids.insert( + ("Foo".to_string(), "src/types.ts".to_string()), + (50, "interface".to_string()), + ); + + let mut edges = Vec::new(); + emit_named_symbol_rows( + &mut edges, + 1, + &imp, + "src/types.ts", + "imports-type", + &ctx, + &symbol_node_ids, + ); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, "imports-type"); + assert_eq!(edges[0].target_id, 50); + } + + #[test] + fn emit_named_symbol_rows_skips_plain_import_of_value_symbol() { + // A plain import of a real function must NOT get a fabricated + // `imports-type` edge — value-symbol consumption credit still comes + // exclusively from an actual `calls` edge (#1833 must not regress + // the existing value-import behaviour). + let ctx = empty_ctx(BTreeMap::new()); + let imp = Import::new("./utils".to_string(), vec!["helper".to_string()], 1); + let mut symbol_node_ids: HashMap<(String, String), (i64, String)> = HashMap::new(); + symbol_node_ids.insert( + ("helper".to_string(), "src/utils.ts".to_string()), + (50, "function".to_string()), + ); + + let mut edges = Vec::new(); + emit_named_symbol_rows( + &mut edges, + 1, + &imp, + "src/utils.ts", + "imports-type", + &ctx, + &symbol_node_ids, + ); + + assert!(edges.is_empty()); + } + + #[test] + fn emit_named_symbol_rows_skips_non_typescript_interface() { + // An 'interface'-kind node outside a .ts/.tsx file (e.g. a Go + // `type ... interface {}`) is runtime-observable in its own + // language, so this heuristic — scoped to TypeScript's compile-time + // erasure — must not credit it on mere import (#1833). + let ctx = empty_ctx(BTreeMap::new()); + let imp = Import::new("./iface".to_string(), vec!["Reader".to_string()], 1); + let mut symbol_node_ids: HashMap<(String, String), (i64, String)> = HashMap::new(); + symbol_node_ids.insert( + ("Reader".to_string(), "src/iface.go".to_string()), + (50, "interface".to_string()), + ); + + let mut edges = Vec::new(); + emit_named_symbol_rows( + &mut edges, + 1, + &imp, + "src/iface.go", + "imports-type", + &ctx, + &symbol_node_ids, + ); + + assert!(edges.is_empty()); + } + + #[test] + fn collect_symbol_lookup_pairs_includes_plain_imports_targeting_ts_files() { + // A plain (non-type-only, non-reexport) import must still be + // collected when its resolved target is a TypeScript file — the + // target might be an interface/type-alias declaration, which + // `emit_named_symbol_rows` can only credit if this pre-pass fetched + // its (id, kind) from the DB (#1833). + let mut file_symbols = BTreeMap::new(); + let plain_ts_import = Import::new("./types".to_string(), vec!["Foo".to_string()], 1); + let plain_py_import = Import::new("./helpers".to_string(), vec!["util".to_string()], 2); + file_symbols.insert( + "src/app.ts".to_string(), + make_symbols_with_imports(vec![], vec![plain_ts_import, plain_py_import]), + ); + let mut ctx = empty_ctx(file_symbols); + ctx.batch_resolved.insert( + "/project/src/app.ts|./types".to_string(), + "src/types.ts".to_string(), + ); + ctx.batch_resolved.insert( + "/project/src/app.ts|./helpers".to_string(), + "src/helpers.py".to_string(), + ); + + let pairs = collect_symbol_lookup_pairs(&ctx); + assert!(pairs.contains(&("Foo".to_string(), "src/types.ts".to_string()))); + assert!(!pairs.contains(&("util".to_string(), "src/helpers.py".to_string()))); + } + + /// Minimal in-memory `edges` schema covering only the columns + /// `insert_edge_chunk` writes. + fn edges_test_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + kind TEXT NOT NULL, + confidence REAL DEFAULT 1.0, + dynamic INTEGER DEFAULT 0 + );", + ) + .unwrap(); + conn + } + + fn sample_edge_row() -> EdgeRow { + EdgeRow { + source_id: 1, + target_id: 2, + kind: "imports".to_string(), + confidence: 1.0, + dynamic: 0, + } + } + + #[test] + fn insert_edges_commits_rows_and_returns_ok_on_success() { + let conn = edges_test_conn(); + let result = insert_edges(&conn, &[sample_edge_row()]); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn insert_edges_returns_ok_for_an_empty_batch() { + let conn = edges_test_conn(); + assert!(insert_edges(&conn, &[]).is_ok()); + } + + /// Regression test for #1827: a transaction-start failure must surface + /// as `Err`, not be swallowed behind a stderr-only warning while the + /// caller sees nothing (`run_pipeline` previously ignored this entirely). + #[test] + fn insert_edges_returns_err_when_transaction_cannot_start() { + let conn = edges_test_conn(); + // SQLite refuses to start a second transaction on a connection that + // already has one active — a reliable way to force + // `conn.unchecked_transaction()` to fail deterministically. + conn.execute_batch("BEGIN").unwrap(); + + let result = insert_edges(&conn, &[sample_edge_row()]); + assert!( + result.is_err(), + "insert_edges must return Err instead of silently no-op'ing when the transaction can't start" + ); + + conn.execute_batch("ROLLBACK").unwrap(); + } } diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs b/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs index 69b4519ab..bb81b8782 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs @@ -94,17 +94,41 @@ fn query_node_ids( Ok(map) } +/// Insert nodes/children/containment edges, plus hash cleanup for removed +/// files. `file_hashes` for CHANGED files is intentionally NOT written here — +/// see [`commit_file_hashes`] below (#1731): committing a file's hash this +/// early (before import/call edges are built) would let the hash claim +/// "up to date" even if edge-building later fails or is interrupted. Deleting +/// a removed file's hash has no such coupling risk (the file has no edges to +/// keep in sync with), so `removed_files` is still handled immediately. pub(crate) fn do_insert_nodes( conn: &Connection, batches: &[InsertNodesBatch], - file_hashes: &[FileHashEntry], removed_files: &[String], ) -> rusqlite::Result<()> { let tx = conn.unchecked_transaction()?; insert_file_nodes(&tx, batches)?; let (contains_edges, param_of_edges) = insert_symbol_nodes(&tx, batches)?; upsert_node_batch(&tx, &contains_edges, ¶m_of_edges)?; - upsert_file_hashes(&tx, file_hashes, removed_files)?; + delete_removed_file_hashes(&tx, removed_files)?; + tx.commit() +} + +/// Commit `file_hashes` for changed files. Called separately from +/// [`do_insert_nodes`], strictly AFTER import/call edges have been rebuilt +/// for these files (#1731) — see that function's doc comment and the call +/// site in `pipeline.rs` Stage 7. Own transaction: node insertion and hash +/// commit are two distinct steps in the coupling this fix establishes, not +/// one atomic unit. +pub(crate) fn commit_file_hashes( + conn: &Connection, + file_hashes: &[FileHashEntry], +) -> rusqlite::Result<()> { + if file_hashes.is_empty() { + return Ok(()); + } + let tx = conn.unchecked_transaction()?; + upsert_file_hashes(&tx, file_hashes)?; tx.commit() } @@ -297,43 +321,57 @@ fn upsert_node_batch( Ok(()) } -/// Phase 4: upsert file hashes and remove hashes for deleted files. No-ops -/// gracefully when the `file_hashes` table has not been created yet (e.g. -/// during the initial schema migration). +/// Returns true iff the `file_hashes` table exists. Both halves of the old +/// combined upsert/delete function (now [`upsert_file_hashes`] and +/// [`delete_removed_file_hashes`]) no-op gracefully when it's absent — e.g. +/// during the initial schema migration. +fn has_file_hashes_table(tx: &rusqlite::Transaction) -> bool { + tx.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='file_hashes'") + .and_then(|mut s| s.query_row([], |_| Ok(true))) + .unwrap_or(false) +} + +/// Phase 4a: upsert file hashes for changed files. Split out from the +/// removed-files delete (below) so the two can be committed at different +/// points in the pipeline — see [`commit_file_hashes`] (#1731). fn upsert_file_hashes( tx: &rusqlite::Transaction, file_hashes: &[FileHashEntry], - removed_files: &[String], ) -> rusqlite::Result<()> { - let has_file_hashes = tx - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='file_hashes'") - .and_then(|mut s| s.query_row([], |_| Ok(true))) - .unwrap_or(false); - - if !has_file_hashes { + if file_hashes.is_empty() || !has_file_hashes_table(tx) { return Ok(()); } - { - let mut upsert = tx.prepare_cached( - "INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) \ - VALUES (?1, ?2, ?3, ?4)", - )?; - for entry in file_hashes { - upsert.execute(params![ - &entry.file, - &entry.hash, - entry.mtime as i64, - entry.size as i64 - ])?; - } + let mut upsert = tx.prepare_cached( + "INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) \ + VALUES (?1, ?2, ?3, ?4)", + )?; + for entry in file_hashes { + upsert.execute(params![ + &entry.file, + &entry.hash, + entry.mtime as i64, + entry.size as i64 + ])?; } - if !removed_files.is_empty() { - let mut delete = tx.prepare_cached("DELETE FROM file_hashes WHERE file = ?1")?; - for file in removed_files { - delete.execute(params![file])?; - } + Ok(()) +} + +/// Phase 4b: remove file_hashes rows for deleted files. Safe to run +/// immediately alongside node insertion (unlike the upsert above) — a +/// removed file has no edges that need to stay in sync with its hash. +fn delete_removed_file_hashes( + tx: &rusqlite::Transaction, + removed_files: &[String], +) -> rusqlite::Result<()> { + if removed_files.is_empty() || !has_file_hashes_table(tx) { + return Ok(()); + } + + let mut delete = tx.prepare_cached("DELETE FROM file_hashes WHERE file = ?1")?; + for file in removed_files { + delete.execute(params![file])?; } Ok(()) diff --git a/crates/codegraph-core/src/domain/graph/resolve.rs b/crates/codegraph-core/src/domain/graph/resolve.rs index 87ed47ffd..ca8df7e82 100644 --- a/crates/codegraph-core/src/domain/graph/resolve.rs +++ b/crates/codegraph-core/src/domain/graph/resolve.rs @@ -1,9 +1,11 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use rayon::prelude::*; -use crate::types::{ImportResolutionInput, PathAliases, ResolvedImport}; +use crate::domain::parser::LanguageKind; +use crate::types::{ImportResolutionInput, PathAliases, ResolvedImport, WorkspacePackage}; /// Check file existence using known_files set when available, falling back to FS. /// @@ -123,14 +125,191 @@ fn resolve_via_alias( None } +// ── Monorepo workspace resolution ─────────────────────────────────── +// +// Mirrors `resolveViaWorkspace()`/`setWorkspaces()`/`isWorkspaceResolved()` +// in `src/domain/graph/resolve.ts`. The JS side owns workspace *detection* +// (parsing pnpm-workspace.yaml / package.json / lerna.json — no equivalent +// exists in Rust, matching the established split documented in +// `infrastructure/config.rs`); this module only consumes the already-detected +// `{ packageName -> { dir, entry } }` map, passed in from JS on every call. + +/// A single workspace package's resolution data. Mirrors the `WorkspaceEntry` +/// interface in `src/infrastructure/config.ts`. Plain (non-napi) struct built +/// from the napi-facing [`WorkspacePackage`] list. +#[derive(Debug, Clone)] +pub struct WorkspaceEntry { + pub dir: String, + pub entry: Option, +} + +/// Convert the napi-facing workspace package list into a lookup map, keyed +/// by package name. +pub fn workspaces_from_packages(packages: &[WorkspacePackage]) -> HashMap { + packages + .iter() + .map(|p| { + ( + p.package_name.clone(), + WorkspaceEntry { + dir: p.dir.clone(), + entry: p.entry.clone(), + }, + ) + }) + .collect() +} + +/// Parse a bare specifier into `(packageName, subpath)`. Mirrors +/// `parseBareSpecifier()` in resolve.ts. +/// Scoped: `"@scope/pkg/sub"` → `("@scope/pkg", "./sub")` +/// Plain: `"pkg/sub"` → `("pkg", "./sub")` +/// No sub: `"pkg"` → `("pkg", ".")` +fn parse_bare_specifier(specifier: &str) -> Option<(String, String)> { + let (package_name, rest) = if specifier.starts_with('@') { + let parts: Vec<&str> = specifier.splitn(3, '/').collect(); + if parts.len() < 2 { + return None; + } + let package_name = format!("{}/{}", parts[0], parts[1]); + let rest = parts.get(2).copied().unwrap_or("").to_string(); + (package_name, rest) + } else { + match specifier.find('/') { + None => (specifier.to_string(), String::new()), + Some(idx) => ( + specifier[..idx].to_string(), + specifier[idx + 1..].to_string(), + ), + } + }; + let subpath = if rest.is_empty() { + ".".to_string() + } else { + format!("./{rest}") + }; + Some((package_name, subpath)) +} + +/// Extensions probed when resolving a workspace subpath import against the +/// filesystem. Mirrors the extension list in `resolveViaWorkspace()`. +const WORKSPACE_PROBE_EXTENSIONS: &[&str] = &[ + "", + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + "/index.ts", + "/index.tsx", + "/index.js", +]; + +/// Resolve a bare specifier through monorepo workspace packages. +/// +/// For `"@myorg/utils"` → finds the workspace package dir → resolves to its +/// entry point. For `"@myorg/utils/sub"` → finds the package dir → filesystem +/// probes `dir/sub` then `dir/src/sub`. +/// +/// Unlike `resolveViaWorkspace()` in resolve.ts, this does not attempt a +/// `package.json` `exports`-field lookup first — the native engine has no +/// `exports`-field resolver at all (tracked separately; see +/// `resolveViaExports()`'s absence from this module). This only affects +/// workspace packages that rely on a conditional `exports` map instead of +/// `main`/`source`/index-file resolution. +fn resolve_via_workspace( + specifier: &str, + workspaces: &HashMap, + root_dir: &str, + known_files: Option<&HashSet>, +) -> Option { + if workspaces.is_empty() { + return None; + } + let (package_name, subpath) = parse_bare_specifier(specifier)?; + let info = workspaces.get(&package_name)?; + + if subpath == "." { + return info.entry.clone(); + } + + let sub_rel = &subpath[2..]; // strip leading "./" + + let base = format!("{}/{}", info.dir.trim_end_matches('/'), sub_rel); + for ext in WORKSPACE_PROBE_EXTENSIONS { + let candidate = format!("{base}{ext}"); + if file_exists(&candidate, known_files, root_dir) { + return Some(candidate); + } + } + + let src_base = format!("{}/src/{}", info.dir.trim_end_matches('/'), sub_rel); + for ext in WORKSPACE_PROBE_EXTENSIONS { + let candidate = format!("{src_base}{ext}"); + if file_exists(&candidate, known_files, root_dir) { + return Some(candidate); + } + } + + None +} + +/// Process-lifetime cache of root-relative paths resolved via a workspace +/// import. Mirrors `_workspaceResolvedPaths` in resolve.ts — read by +/// `compute_confidence()` to grant workspace-resolved imports a 0.95 +/// confidence floor regardless of directory distance. +/// +/// Populated as a side effect of `resolve_import_path`/`resolve_imports_batch` +/// and reset once per build by the callers that own "start of build" timing: +/// `resolve_imports` (the per-call FFI entry point, called exactly once per +/// JS-driven build by `resolveImportsBatch()`) and `pipeline_setup` (the Rust +/// orchestrator's once-per-build setup stage). Later same-build calls (e.g. +/// the barrel re-parse loop, which calls `resolve_imports_batch` repeatedly) +/// only add to the set, matching `setWorkspaces()`'s clear-once-then-accumulate +/// contract on the JS side. +fn workspace_resolved_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Clear the workspace-resolved-paths cache. Call once per build, before any +/// resolution runs, mirroring `_workspaceResolvedPaths.clear()` inside +/// `setWorkspaces()`. +pub fn reset_workspace_resolved_paths() { + if let Ok(mut set) = workspace_resolved_cache().lock() { + set.clear(); + } +} + +fn mark_workspace_resolved(path: &str) { + if let Ok(mut set) = workspace_resolved_cache().lock() { + set.insert(path.to_string()); + } +} + +fn is_workspace_resolved(path: &str) -> bool { + workspace_resolved_cache() + .lock() + .map(|set| set.contains(path)) + .unwrap_or(false) +} + /// Resolve a single import path, mirroring `resolveImportPath()` in builder.js. pub fn resolve_import_path( from_file: &str, import_source: &str, root_dir: &str, aliases: &PathAliases, + workspaces: Option<&HashMap>, ) -> String { - resolve_import_path_inner(from_file, import_source, root_dir, aliases, None) + resolve_import_path_inner( + from_file, + import_source, + root_dir, + aliases, + None, + workspaces, + ) } /// Inner implementation with optional known_files cache. @@ -146,17 +325,32 @@ fn relativize_to_root(candidate: &str, root_dir: &str) -> String { } } -/// Resolve a non-relative (alias or bare) import source. Returns the -/// resolved path or the raw source if no alias matches (bare specifier). +/// Resolve a non-relative (alias, workspace, or bare) import source. Returns +/// the resolved path or the raw source if nothing matches (bare specifier). +/// +/// Order mirrors `resolveImportPathJS()`: aliases take priority (tsconfig/ +/// jsconfig path mappings), then workspace packages ("workspace packages +/// take priority over node_modules" — resolve.ts), then the raw specifier is +/// returned unresolved. fn resolve_non_relative_import( import_source: &str, root_dir: &str, aliases: &PathAliases, known_files: Option<&HashSet>, + workspaces: Option<&HashMap>, ) -> String { if let Some(alias_resolved) = resolve_via_alias(import_source, aliases, root_dir, known_files) { return relativize_to_root(&alias_resolved, root_dir); } + if let Some(workspaces) = workspaces { + if let Some(ws_resolved) = + resolve_via_workspace(import_source, workspaces, root_dir, known_files) + { + let rel = relativize_to_root(&ws_resolved, root_dir); + mark_workspace_resolved(&rel); + return rel; + } + } import_source.to_string() } @@ -226,9 +420,16 @@ fn resolve_import_path_inner( root_dir: &str, aliases: &PathAliases, known_files: Option<&HashSet>, + workspaces: Option<&HashMap>, ) -> String { if !import_source.starts_with('.') { - return resolve_non_relative_import(import_source, root_dir, aliases, known_files); + return resolve_non_relative_import( + import_source, + root_dir, + aliases, + known_files, + workspaces, + ); } let dir = Path::new(from_file).parent().unwrap_or(Path::new("")); @@ -247,8 +448,76 @@ fn resolve_import_path_inner( relativize_to_root(&resolved.display().to_string().replace('\\', "/"), root_dir) } +/// All ancestor directories of `dir`, starting with `dir` itself, walking up to the root. +fn ancestor_chain(dir: &str) -> Vec { + let mut chain = vec![dir.to_string()]; + let mut cur = dir.to_string(); + while let Some(parent) = Path::new(&cur).parent() { + let parent_str = parent.display().to_string(); + chain.push(parent_str.clone()); + cur = parent_str; + } + chain +} + +/// Directory-tree distance between two directories: hops up from `a` to the +/// nearest ancestor shared with `b`, plus hops down from there to `b`. +/// +/// Symmetric and depth-independent — unlike a fixed-depth equality check +/// (e.g. comparing the parent-of-parent of `a` to the parent-of-parent of +/// `b`, as `compute_confidence` used to), this correctly scores both sibling +/// directories (common parent) and direct ancestor/descendant directories +/// (one nested inside the other) regardless of how deep either path is. The +/// fixed-depth check only matched when both files sat at the *same* depth, +/// so e.g. a file in `graph/algorithms/*.rs` calling a method declared in +/// the shallower `graph/model.rs` was scored as maximally distant (issue #1769). +fn directory_distance(a: &str, b: &str) -> usize { + let chain_a = ancestor_chain(a); + let chain_b = ancestor_chain(b); + for (i, dir_a) in chain_a.iter().enumerate() { + if let Some(j) = chain_b.iter().position(|dir_b| dir_b == dir_a) { + return i + j; + } + } + usize::MAX +} + +/// Coarse "language family" for a file, derived from its extension via +/// `LanguageKind::from_extension`. Collapses TypeScript/Tsx into the same +/// family as JavaScript: despite being distinct `LanguageKind` variants (one +/// per tree-sitter grammar), `.ts`/`.tsx` files routinely import from and +/// call into `.js` files and vice versa within the same project (this +/// codebase's own `src/` tree does this throughout) — treating them as +/// separate families would reject huge amounts of legitimate same-project +/// resolution. Every other `LanguageKind` variant keeps its own family, +/// preserving `from_extension`'s existing per-language extension groupings +/// (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`). +fn language_family(file: &str) -> Option { + match LanguageKind::from_extension(file) { + Some(LanguageKind::TypeScript) | Some(LanguageKind::Tsx) => Some(LanguageKind::JavaScript), + other => other, + } +} + +/// True when `file_a` and `file_b` belong to the same language family, or +/// when either extension is unrecognised (ambiguous cases are not rejected — +/// they fall through to normal scoring). False only when both extensions are +/// recognised AND resolve to different families. +/// +/// Guards the global-by-name call-resolution fallback against matching a +/// same-named symbol across unrelated languages — e.g. a Ruby file's bare +/// `load` call has no static relationship to a same-named `load` export in a +/// JS file, even when both happen to live in the same directory (issue +/// #1783). Mirrors `isSameLanguageFamily()` in resolve.ts. +pub fn is_same_language_family(file_a: &str, file_b: &str) -> bool { + match (language_family(file_a), language_family(file_b)) { + (Some(a), Some(b)) => a == b, + _ => true, + } +} + /// Compute proximity-based confidence for call resolution. -/// Mirrors `computeConfidence()` in builder.js. +/// Mirrors `computeConfidence()` in resolve.ts. pub fn compute_confidence( caller_file: &str, target_file: &str, @@ -264,6 +533,20 @@ pub fn compute_confidence( if imp == target_file { return 1.0; } + // Workspace-resolved imports get high confidence even across package + // boundaries — mirrors the `_workspaceResolvedPaths` check in + // `computeConfidenceJS()` (resolve.ts), backed here by the + // process-lifetime cache populated by `resolve_import_path`/ + // `resolve_imports_batch` (issue #1927). + if is_workspace_resolved(imp) { + return 0.95; + } + } + // Cross-language candidates are never legitimate call targets (#1783) — + // reject before scoring proximity so a same-directory, same-named symbol + // in an unrelated language can never pass the resolver's 0.5 threshold. + if !is_same_language_family(caller_file, target_file) { + return 0.0; } let caller_dir = Path::new(caller_file) @@ -275,24 +558,12 @@ pub fn compute_confidence( .map(|p| p.display().to_string()) .unwrap_or_default(); - if caller_dir == target_dir { - return 0.7; + match directory_distance(&caller_dir, &target_dir) { + 0 => 0.7, // same directory + 1 => 0.6, // direct parent/child directory + 2 => 0.5, // sibling directories, or a grandparent/grandchild pair + _ => 0.3, } - - let caller_parent = Path::new(&caller_dir) - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - let target_parent = Path::new(&target_dir) - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - - if caller_parent == target_parent { - return 0.5; - } - - 0.3 } /// Batch resolve multiple imports (parallelized with rayon). @@ -301,6 +572,7 @@ pub fn resolve_imports_batch( root_dir: &str, aliases: &PathAliases, known_files: Option<&HashSet>, + workspaces: Option<&HashMap>, ) -> Vec { inputs .par_iter() @@ -311,6 +583,7 @@ pub fn resolve_imports_batch( root_dir, aliases, known_files, + workspaces, ); ResolvedImport { from_file: input.from_file.clone(), @@ -406,6 +679,7 @@ mod tests { "/project", &aliases, Some(&known), + None, ); assert_eq!(result, "src/bar.ts"); } @@ -427,7 +701,392 @@ mod tests { "/project", &aliases, Some(&known), + None, ); assert_eq!(result, "src/utils.ts"); } + + // Regression tests for #1769: a fixed-depth "grandparent equality" check + // used to compare the parent of `caller_dir` to the parent of `target_dir`, + // which only matched when both files sat at the *same* depth. A file in a + // subdirectory calling a method declared in its direct parent directory + // (e.g. `graph/algorithms/bfs.rs` calling `graph/model.rs`) was scored as + // maximally distant (0.3) purely because the two files were nested at + // different depths — well below the 0.5 threshold used by the call-edge + // resolver's typed-method lookup, silently dropping the call edge. + + #[test] + fn compute_confidence_scores_parent_child_dirs_above_resolver_threshold() { + let conf = compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None); + assert!(conf >= 0.5, "expected >= 0.5, got {conf}"); + } + + #[test] + fn compute_confidence_is_symmetric_for_parent_child_dirs() { + let caller_deeper = + compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None); + let target_deeper = + compute_confidence("src/graph/model.rs", "src/graph/algorithms/bfs.rs", None); + assert_eq!(caller_deeper, target_deeper); + } + + #[test] + fn compute_confidence_ranks_parent_child_between_same_dir_and_sibling() { + let same_dir = compute_confidence("src/graph/a.rs", "src/graph/b.rs", None); + let parent_child = + compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None); + // True siblings: both one level below `src`, at equal depth. + let sibling = compute_confidence("src/graph/a.rs", "src/features/b.rs", None); + assert!(same_dir > parent_child); + assert!(parent_child > sibling); + } + + #[test] + fn compute_confidence_scores_two_level_nesting_at_or_above_sibling_tier() { + // the graph/algorithms/leiden/*.rs -> graph/model.rs shape from #1769. + let conf = compute_confidence( + "src/graph/algorithms/leiden/cpm.rs", + "src/graph/model.rs", + None, + ); + assert!(conf >= 0.5, "expected >= 0.5, got {conf}"); + } + + #[test] + fn compute_confidence_still_scores_unrelated_deep_files_as_distant() { + let conf = compute_confidence( + "src/graph/algorithms/leiden/cpm.rs", + "src/mcp/server.rs", + None, + ); + assert!(conf < 0.5, "expected < 0.5, got {conf}"); + } + + #[test] + fn directory_distance_same_dir_is_zero() { + assert_eq!(directory_distance("src/graph", "src/graph"), 0); + } + + #[test] + fn directory_distance_direct_parent_child_is_one() { + assert_eq!(directory_distance("src/graph/algorithms", "src/graph"), 1); + assert_eq!(directory_distance("src/graph", "src/graph/algorithms"), 1); + } + + #[test] + fn directory_distance_siblings_is_two() { + // Both dirs are one level below `src` — true siblings at equal depth. + assert_eq!(directory_distance("src/graph", "src/features"), 2); + } + + #[test] + fn directory_distance_unequal_depth_non_siblings_is_three() { + // `algorithms` is nested inside `graph`, which is a sibling of `features` — + // not a direct sibling pair despite sharing the `src` ancestor. + assert_eq!(directory_distance("src/graph/algorithms", "src/features"), 3); + } + + // Regression tests for #1783: the global-by-name call-resolution fallback + // had no language-consistency check at all, so a bare-name call with no + // import/receiver match could resolve against a same-named symbol in a + // completely unrelated language — e.g. a Ruby file's builtin `Kernel#load` + // call matched a JS ESM loader hook's unrelated `load` export purely + // because both files sat in the same directory (confidence 0.7 from + // proximity alone, well above the resolver's 0.5 threshold). + + #[test] + fn is_same_language_family_rejects_ruby_and_js() { + assert!(!is_same_language_family("tracer/ruby-tracer.rb", "tracer/loader-hooks.mjs")); + } + + #[test] + fn is_same_language_family_rejects_python_and_go() { + assert!(!is_same_language_family("src/main.py", "src/main.go")); + } + + #[test] + fn is_same_language_family_accepts_same_extension() { + assert!(is_same_language_family("src/a.rb", "lib/b.rb")); + } + + #[test] + fn is_same_language_family_merges_javascript_and_typescript() { + assert!(is_same_language_family("src/a.ts", "src/b.js")); + assert!(is_same_language_family("src/a.tsx", "src/b.mjs")); + assert!(is_same_language_family("src/a.cjs", "src/b.jsx")); + } + + #[test] + fn is_same_language_family_merges_c_source_and_header() { + assert!(is_same_language_family("src/a.c", "src/a.h")); + } + + #[test] + fn is_same_language_family_merges_cpp_source_and_header_variants() { + assert!(is_same_language_family("src/a.cpp", "src/a.hpp")); + assert!(is_same_language_family("src/a.cc", "src/a.cxx")); + } + + #[test] + fn is_same_language_family_does_not_merge_c_and_cpp() { + assert!(!is_same_language_family("src/a.c", "src/a.cpp")); + } + + #[test] + fn is_same_language_family_does_not_reject_unrecognised_extensions() { + // Ambiguous (unrecognised) extensions fall through rather than being rejected. + assert!(is_same_language_family("README", "src/b.rb")); + assert!(is_same_language_family("src/a.rb", "Makefile")); + } + + #[test] + fn compute_confidence_rejects_cross_language_same_directory_match() { + // The exact #1783 repro shape: same directory, different languages. + let conf = compute_confidence( + "tests/benchmarks/resolution/tracer/ruby-tracer.rb", + "tests/benchmarks/resolution/tracer/loader-hooks.mjs", + None, + ); + assert_eq!(conf, 0.0); + } + + #[test] + fn compute_confidence_still_scores_same_language_same_directory_pair() { + let conf = compute_confidence( + "tests/benchmarks/resolution/tracer/ruby-tracer.rb", + "tests/benchmarks/resolution/tracer/other-tracer.rb", + None, + ); + assert_eq!(conf, 0.7); + } + + #[test] + fn compute_confidence_does_not_regress_same_project_js_ts_resolution() { + // A .ts caller resolving a same-directory .js target must be unaffected — + // TS/JS are one family despite being different LanguageKind variants. + let conf = compute_confidence("src/graph/a.ts", "src/graph/b.js", None); + assert_eq!(conf, 0.7); + } + + // Regression tests for #1927: `resolve_import_path_inner` had no + // workspace-awareness at all, so a bare monorepo-package specifier (e.g. + // `import "@myorg/lib"`) fell straight through to `resolve_non_relative_import`'s + // raw-specifier fallback under the native engine, unlike the WASM/JS engine's + // `resolveViaWorkspace()`. + + fn make_workspaces(entries: &[(&str, &str, Option<&str>)]) -> HashMap { + entries + .iter() + .map(|(name, dir, entry)| { + ( + name.to_string(), + WorkspaceEntry { + dir: dir.to_string(), + entry: entry.map(|e| e.to_string()), + }, + ) + }) + .collect() + } + + #[test] + fn parse_bare_specifier_scoped_package_root() { + assert_eq!( + parse_bare_specifier("@myorg/core"), + Some(("@myorg/core".to_string(), ".".to_string())) + ); + } + + #[test] + fn parse_bare_specifier_scoped_package_subpath() { + assert_eq!( + parse_bare_specifier("@myorg/core/src/helpers"), + Some(("@myorg/core".to_string(), "./src/helpers".to_string())) + ); + } + + #[test] + fn parse_bare_specifier_plain_package() { + assert_eq!( + parse_bare_specifier("lodash"), + Some(("lodash".to_string(), ".".to_string())) + ); + assert_eq!( + parse_bare_specifier("lodash/fp"), + Some(("lodash".to_string(), "./fp".to_string())) + ); + } + + #[test] + fn parse_bare_specifier_rejects_malformed_scoped_specifier() { + assert_eq!(parse_bare_specifier("@myorg"), None); + } + + #[test] + fn resolve_via_workspace_resolves_root_import_to_entry() { + let workspaces = make_workspaces(&[( + "@myorg/core", + "packages/core", + Some("packages/core/src/index.js"), + )]); + let result = resolve_via_workspace("@myorg/core", &workspaces, "/project", None); + assert_eq!(result, Some("packages/core/src/index.js".to_string())); + } + + #[test] + fn resolve_via_workspace_returns_none_when_entry_missing() { + let workspaces = make_workspaces(&[("@myorg/broken", "packages/broken", None)]); + let result = resolve_via_workspace("@myorg/broken", &workspaces, "/project", None); + assert_eq!(result, None); + } + + #[test] + fn resolve_via_workspace_resolves_subpath_via_known_files_probe() { + let mut known = HashSet::new(); + known.insert("packages/core/src/helpers.js".to_string()); + let workspaces = make_workspaces(&[("@myorg/core", "packages/core", None)]); + let result = resolve_via_workspace( + "@myorg/core/src/helpers", + &workspaces, + "/project", + Some(&known), + ); + assert_eq!(result, Some("packages/core/src/helpers.js".to_string())); + } + + #[test] + fn resolve_via_workspace_resolves_subpath_via_src_convention() { + let mut known = HashSet::new(); + known.insert("packages/core/src/helpers.js".to_string()); + let workspaces = make_workspaces(&[("@myorg/core", "packages/core", None)]); + let result = + resolve_via_workspace("@myorg/core/helpers", &workspaces, "/project", Some(&known)); + assert_eq!(result, Some("packages/core/src/helpers.js".to_string())); + } + + #[test] + fn resolve_via_workspace_returns_none_for_unknown_package() { + let workspaces = make_workspaces(&[("@myorg/core", "packages/core", None)]); + assert_eq!( + resolve_via_workspace("@myorg/unknown", &workspaces, "/project", None), + None + ); + } + + #[test] + fn resolve_via_workspace_returns_none_when_no_workspaces_registered() { + let workspaces: HashMap = HashMap::new(); + assert_eq!( + resolve_via_workspace("@myorg/core", &workspaces, "/project", None), + None + ); + } + + #[test] + fn resolve_non_relative_import_prefers_workspace_over_raw_specifier() { + let workspaces = make_workspaces(&[( + "@myorg/lib", + "packages/lib", + Some("/project/packages/lib/src/index.js"), + )]); + let aliases = PathAliases { + base_url: None, + paths: vec![], + }; + let resolved = resolve_non_relative_import( + "@myorg/lib", + "/project", + &aliases, + None, + Some(&workspaces), + ); + assert_eq!(resolved, "packages/lib/src/index.js"); + } + + #[test] + fn resolve_non_relative_import_falls_back_to_raw_specifier_without_workspace_match() { + let workspaces = make_workspaces(&[("@myorg/lib", "packages/lib", None)]); + let aliases = PathAliases { + base_url: None, + paths: vec![], + }; + let resolved = + resolve_non_relative_import("lodash", "/project", &aliases, None, Some(&workspaces)); + assert_eq!(resolved, "lodash"); + } + + // Serializes access to the process-lifetime workspace-resolved-paths + // cache: `cargo test` runs tests in parallel threads within one process, + // and `reset_workspace_resolved_paths()` would otherwise race with + // concurrent assertions in other tests below. + static WORKSPACE_CACHE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn with_workspace_cache_lock(f: F) { + let guard = WORKSPACE_CACHE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_workspace_resolved_paths(); + f(); + reset_workspace_resolved_paths(); + drop(guard); + } + + #[test] + fn resolve_import_path_marks_workspace_resolved_paths() { + with_workspace_cache_lock(|| { + let workspaces = make_workspaces(&[( + "@myorg/lib", + "packages/lib", + Some("/project/packages/lib/src/index.js"), + )]); + let aliases = PathAliases { + base_url: None, + paths: vec![], + }; + let resolved = resolve_import_path( + "/project/apps/web/src/app.js", + "@myorg/lib", + "/project", + &aliases, + Some(&workspaces), + ); + assert_eq!(resolved, "packages/lib/src/index.js"); + assert!(is_workspace_resolved("packages/lib/src/index.js")); + }); + } + + #[test] + fn compute_confidence_returns_0_95_for_workspace_resolved_import() { + with_workspace_cache_lock(|| { + mark_workspace_resolved("packages/lib/src/index.js"); + let conf = compute_confidence( + "apps/web/src/app.js", + "packages/lib/src/utils.js", + Some("packages/lib/src/index.js"), + ); + assert_eq!(conf, 0.95); + }); + } + + #[test] + fn compute_confidence_does_not_boost_non_workspace_imports() { + with_workspace_cache_lock(|| { + let conf = compute_confidence( + "apps/web/src/app.js", + "some/distant/file.js", + Some("some/other/import.js"), + ); + assert!(conf < 0.95); + }); + } + + #[test] + fn reset_workspace_resolved_paths_clears_previously_marked_entries() { + with_workspace_cache_lock(|| { + mark_workspace_resolved("packages/lib/src/index.js"); + assert!(is_workspace_resolved("packages/lib/src/index.js")); + reset_workspace_resolved_paths(); + assert!(!is_workspace_resolved("packages/lib/src/index.js")); + }); + } } diff --git a/crates/codegraph-core/src/extractors/bash.rs b/crates/codegraph-core/src/extractors/bash.rs index d0e3fec72..d8e30213c 100644 --- a/crates/codegraph-core/src/extractors/bash.rs +++ b/crates/codegraph-core/src/extractors/bash.rs @@ -30,6 +30,7 @@ fn match_bash_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth complexity: compute_all_metrics(node, source, "bash"), cfg: build_function_cfg(node, "bash", source), children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/c.rs b/crates/codegraph-core/src/extractors/c.rs index 0fc5310b0..fb5c926fa 100644 --- a/crates/codegraph-core/src/extractors/c.rs +++ b/crates/codegraph-core/src/extractors/c.rs @@ -193,6 +193,7 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u complexity: compute_all_metrics(node, source, "c"), cfg: build_function_cfg(node, "c", source), children: opt_children(children), + bodyless: None, }); } } @@ -212,6 +213,7 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -230,6 +232,7 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -246,6 +249,7 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -274,6 +278,7 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/clojure.rs b/crates/codegraph-core/src/extractors/clojure.rs index 160bb5f9e..710642985 100644 --- a/crates/codegraph-core/src/extractors/clojure.rs +++ b/crates/codegraph-core/src/extractors/clojure.rs @@ -190,6 +190,7 @@ fn handle_ns_form(node: &Node, source: &[u8], symbols: &mut FileSymbols) -> Opti complexity: None, cfg: None, children: None, + bodyless: None, }); // Scan for nested `(:require ...)`, `(:import ...)`, `(:use ...)` forms. @@ -269,6 +270,7 @@ fn handle_def_form( complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -302,6 +304,7 @@ fn handle_defn_form( complexity: compute_all_metrics(node, source, "clojure"), cfg: build_function_cfg(node, "clojure", source), children: opt_children(params), + bodyless: None, }); } @@ -351,6 +354,7 @@ fn handle_defprotocol(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -368,6 +372,7 @@ fn handle_defrecord(node: &Node, source: &[u8], symbols: &mut FileSymbols, kind: complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/cpp.rs b/crates/codegraph-core/src/extractors/cpp.rs index cc693c807..55682aafc 100644 --- a/crates/codegraph-core/src/extractors/cpp.rs +++ b/crates/codegraph-core/src/extractors/cpp.rs @@ -203,6 +203,7 @@ fn handle_cpp_function_definition(node: &Node, source: &[u8], symbols: &mut File complexity: compute_all_metrics(node, source, "cpp"), cfg: build_function_cfg(node, "cpp", source), children: opt_children(children), + bodyless: None, }); } } @@ -222,6 +223,7 @@ fn handle_cpp_class_specifier(node: &Node, source: &[u8], symbols: &mut FileSymb complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_cpp_base_classes(node, source, &class_name, symbols); } @@ -242,6 +244,7 @@ fn handle_cpp_struct_specifier(node: &Node, source: &[u8], symbols: &mut FileSym complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_cpp_base_classes(node, source, &struct_name, symbols); } @@ -259,6 +262,7 @@ fn handle_cpp_enum_specifier(node: &Node, source: &[u8], symbols: &mut FileSymbo complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -274,6 +278,7 @@ fn handle_cpp_namespace_definition(node: &Node, source: &[u8], symbols: &mut Fil complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -301,6 +306,7 @@ fn handle_cpp_type_definition(node: &Node, source: &[u8], symbols: &mut FileSymb complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index 8d3f45aec..a32e2fdca 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -61,6 +61,7 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_csharp_base_types(node, &class_name, source, symbols); } @@ -77,6 +78,7 @@ fn handle_struct_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); extract_csharp_base_types(node, &name, source, symbols); } @@ -93,6 +95,7 @@ fn handle_record_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); extract_csharp_base_types(node, &name, source, symbols); } @@ -109,6 +112,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -127,6 +131,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: Some(child.child_by_field_name("body").is_none()), }); } } @@ -146,6 +151,7 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -168,6 +174,7 @@ fn handle_method_or_ctor(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: compute_all_metrics(node, source, "csharp"), cfg: build_function_cfg(node, "csharp", source), children: opt_children(children), + bodyless: Some(node.child_by_field_name("body").is_none()), }); } @@ -204,6 +211,7 @@ fn handle_property_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/cuda.rs b/crates/codegraph-core/src/extractors/cuda.rs index 2ce25ef53..abc9a5909 100644 --- a/crates/codegraph-core/src/extractors/cuda.rs +++ b/crates/codegraph-core/src/extractors/cuda.rs @@ -362,6 +362,7 @@ fn handle_cuda_function_definition(node: &Node, source: &[u8], symbols: &mut Fil complexity: compute_all_metrics(node, source, "cpp"), cfg: build_function_cfg(node, "cpp", source), children: opt_children(children), + bodyless: None, }); } } @@ -382,6 +383,7 @@ fn handle_cuda_class_specifier(node: &Node, source: &[u8], symbols: &mut FileSym complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_cuda_base_classes(node, source, &class_name, symbols); } @@ -403,6 +405,7 @@ fn handle_cuda_struct_specifier(node: &Node, source: &[u8], symbols: &mut FileSy complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -419,6 +422,7 @@ fn handle_cuda_enum_specifier(node: &Node, source: &[u8], symbols: &mut FileSymb complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -434,6 +438,7 @@ fn handle_cuda_namespace_definition(node: &Node, source: &[u8], symbols: &mut Fi complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -464,6 +469,7 @@ fn handle_cuda_type_definition(node: &Node, source: &[u8], symbols: &mut FileSym complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/dart.rs b/crates/codegraph-core/src/extractors/dart.rs index 29d36e042..cda617039 100644 --- a/crates/codegraph-core/src/extractors/dart.rs +++ b/crates/codegraph-core/src/extractors/dart.rs @@ -92,6 +92,7 @@ fn handle_dart_class(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -148,6 +149,7 @@ fn extract_dart_class_methods(body: &Node, class_name: &str, source: &[u8], symb complexity: compute_all_metrics(&sig, source, "dart"), cfg: build_function_cfg(&sig, "dart", source), children: None, + bodyless: None, }); } } @@ -202,6 +204,7 @@ fn handle_dart_enum(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -220,6 +223,7 @@ fn handle_dart_mixin(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -238,6 +242,7 @@ fn handle_dart_extension(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -256,6 +261,7 @@ fn handle_dart_function_sig(node: &Node, source: &[u8], symbols: &mut FileSymbol complexity: compute_all_metrics(node, source, "dart"), cfg: build_function_cfg(node, "dart", source), children: None, + bodyless: None, }); } @@ -305,6 +311,7 @@ fn handle_dart_type_alias(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/elixir.rs b/crates/codegraph-core/src/extractors/elixir.rs index 237d62449..51527966b 100644 --- a/crates/codegraph-core/src/extractors/elixir.rs +++ b/crates/codegraph-core/src/extractors/elixir.rs @@ -73,6 +73,7 @@ fn handle_defmodule(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } @@ -142,6 +143,7 @@ fn handle_def_function(node: &Node, source: &[u8], symbols: &mut FileSymbols, _k complexity: compute_all_metrics(node, source, "elixir"), cfg: build_function_cfg(node, "elixir", source), children: opt_children(params), + bodyless: None, }); } @@ -325,6 +327,7 @@ fn handle_defprotocol(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -347,6 +350,7 @@ fn handle_defimpl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/erlang.rs b/crates/codegraph-core/src/extractors/erlang.rs index 8d1d07b4a..68f74a07d 100644 --- a/crates/codegraph-core/src/extractors/erlang.rs +++ b/crates/codegraph-core/src/extractors/erlang.rs @@ -56,6 +56,7 @@ fn handle_module_attr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -97,6 +98,7 @@ fn handle_record_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } @@ -123,6 +125,7 @@ fn handle_type_alias(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -175,6 +178,7 @@ fn handle_function_clause(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: opt_children(params), + bodyless: None, }); } @@ -244,6 +248,7 @@ fn handle_define(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/fsharp.rs b/crates/codegraph-core/src/extractors/fsharp.rs index f9a442701..24a4e6ab1 100644 --- a/crates/codegraph-core/src/extractors/fsharp.rs +++ b/crates/codegraph-core/src/extractors/fsharp.rs @@ -80,6 +80,7 @@ fn handle_named_module(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -110,6 +111,7 @@ fn handle_module_defn(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -157,6 +159,7 @@ fn handle_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(&end, source, "fsharp"), cfg: build_function_cfg(&end, "fsharp", source), children: opt_children(params), + bodyless: None, }); } @@ -226,6 +229,7 @@ fn handle_type_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -406,6 +410,7 @@ fn handle_value_definition(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/gleam.rs b/crates/codegraph-core/src/extractors/gleam.rs index db29a2b13..2145e8f88 100644 --- a/crates/codegraph-core/src/extractors/gleam.rs +++ b/crates/codegraph-core/src/extractors/gleam.rs @@ -49,6 +49,7 @@ fn handle_function(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "gleam"), cfg: build_function_cfg(node, "gleam", source), children: opt_children(params), + bodyless: None, }); } @@ -72,6 +73,7 @@ fn handle_external_function(node: &Node, source: &[u8], symbols: &mut FileSymbol complexity: None, cfg: None, children: opt_children(params), + bodyless: None, }); } @@ -139,6 +141,7 @@ fn handle_type_definition(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } @@ -160,6 +163,7 @@ fn handle_type_alias(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -181,6 +185,7 @@ fn handle_constant(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index 794ba200e..675f29b16 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -44,6 +44,7 @@ fn handle_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "go"), cfg: build_function_cfg(node, "go", source), children: opt_children(children), + bodyless: None, }); } } @@ -66,6 +67,7 @@ fn handle_method_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "go"), cfg: build_function_cfg(node, "go", source), children: opt_children(children), + bodyless: Some(node.child_by_field_name("body").is_none()), }); } @@ -105,6 +107,7 @@ fn handle_type_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } "interface_type" => { @@ -117,6 +120,7 @@ fn handle_type_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); extract_go_interface_methods(&type_node, &name, source, symbols); } @@ -130,6 +134,7 @@ fn handle_type_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -150,6 +155,7 @@ fn extract_go_interface_methods(type_node: &Node, iface_name: &str, source: &[u8 complexity: None, cfg: None, children: None, + bodyless: Some(member.child_by_field_name("body").is_none()), }); } } @@ -169,6 +175,7 @@ fn handle_const_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -501,6 +508,10 @@ mod tests { let names: Vec<&str> = s.definitions.iter().map(|d| d.name.as_str()).collect(); assert!(names.contains(&"Server")); assert!(names.contains(&"Server.Start")); + // A real receiver method has a body — must not be marked bodyless (#1922: + // a dotted name alone must never be treated as a signature-only stub). + let start = s.definitions.iter().find(|d| d.name == "Server.Start").unwrap(); + assert_ne!(start.bodyless, Some(true)); } #[test] @@ -509,6 +520,33 @@ mod tests { let names: Vec<&str> = s.definitions.iter().map(|d| d.name.as_str()).collect(); assert!(names.contains(&"Reader")); assert!(names.contains(&"Reader.Read")); + // An interface method_elem structurally has no body field — must be marked + // bodyless so the WASM/native "needs complexity" gate correctly skips it + // instead of forcing an unnecessary fallback (#1922). + let read = s.definitions.iter().find(|d| d.name == "Reader.Read").unwrap(); + assert_eq!(read.bodyless, Some(true)); + } + + #[test] + fn receiver_only_file_has_no_bodyless_methods() { + // Reproduces the exact #1922 report: a file with only receiver methods + // (no free functions), each with a real body. + let s = parse_go( + "package main\n\ + type Repo struct{ items map[string]int }\n\ + func (r *Repo) Save(id string, v int) bool { return true }\n\ + func (r *Repo) Find(id string) (int, bool) { return 0, false }\n", + ); + let methods: Vec<_> = s + .definitions + .iter() + .filter(|d| d.kind == "method") + .collect(); + assert_eq!(methods.len(), 2); + for m in methods { + assert_ne!(m.bodyless, Some(true), "{} should not be bodyless", m.name); + assert!(m.complexity.is_some(), "{} should have complexity computed", m.name); + } } #[test] diff --git a/crates/codegraph-core/src/extractors/groovy.rs b/crates/codegraph-core/src/extractors/groovy.rs index 0c59aa10c..2fca0b3b3 100644 --- a/crates/codegraph-core/src/extractors/groovy.rs +++ b/crates/codegraph-core/src/extractors/groovy.rs @@ -83,6 +83,7 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); // Superclass: `superclass` field wraps a `_type` child (type_identifier / @@ -168,6 +169,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); // `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent @@ -212,6 +214,7 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(members), + bodyless: None, }); } @@ -235,6 +238,7 @@ fn handle_method_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "groovy"), cfg: build_function_cfg(node, "groovy", source), children: opt_children(params), + bodyless: None, }); } @@ -256,6 +260,7 @@ fn handle_constructor_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: compute_all_metrics(node, source, "groovy"), cfg: build_function_cfg(node, "groovy", source), children: opt_children(params), + bodyless: None, }); } @@ -272,6 +277,7 @@ fn handle_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "groovy"), cfg: build_function_cfg(node, "groovy", source), children: opt_children(params), + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/haskell.rs b/crates/codegraph-core/src/extractors/haskell.rs index dea5cfb7e..30686518f 100644 --- a/crates/codegraph-core/src/extractors/haskell.rs +++ b/crates/codegraph-core/src/extractors/haskell.rs @@ -48,6 +48,7 @@ fn handle_haskell_function(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: compute_all_metrics(node, source, "haskell"), cfg: build_function_cfg(node, "haskell", source), children: opt_children(params), + bodyless: None, }); } @@ -136,6 +137,7 @@ fn handle_haskell_bind(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -174,6 +176,7 @@ fn handle_haskell_data_type(node: &Node, source: &[u8], symbols: &mut FileSymbol complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } @@ -192,6 +195,7 @@ fn handle_haskell_newtype(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -210,6 +214,7 @@ fn handle_haskell_type_synonym(node: &Node, source: &[u8], symbols: &mut FileSym complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -228,6 +233,7 @@ fn handle_haskell_class(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -246,6 +252,7 @@ fn handle_haskell_instance(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/hcl.rs b/crates/codegraph-core/src/extractors/hcl.rs index 23a2ea4f1..4aebf9bac 100644 --- a/crates/codegraph-core/src/extractors/hcl.rs +++ b/crates/codegraph-core/src/extractors/hcl.rs @@ -69,6 +69,7 @@ fn extract_block_attributes(node: &Node, source: &[u8]) -> Vec { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -124,6 +125,7 @@ fn match_hcl_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: complexity: None, cfg: None, children, + bodyless: None, }); if block_type == "module" { extract_module_source(node, source, symbols); diff --git a/crates/codegraph-core/src/extractors/helpers.rs b/crates/codegraph-core/src/extractors/helpers.rs index 6e46ec44c..8d989dc49 100644 --- a/crates/codegraph-core/src/extractors/helpers.rs +++ b/crates/codegraph-core/src/extractors/helpers.rs @@ -25,6 +25,7 @@ pub fn child_def(name: String, kind: &str, line: u32) -> Definition { complexity: None, cfg: None, children: None, + bodyless: None, } } @@ -211,6 +212,13 @@ pub struct LangAstConfig { /// Single-char prefixes that can appear before string quotes (e.g. `r`, `b`, `f`, `u` for Python). /// Multi-char combos like `rb`, `fr` are handled by stripping each char in sequence. pub string_prefixes: &'static [char], + /// When true, a node is only classified if `node.is_named()` — guards against + /// anonymous grammar tokens whose `kind()` string collides with a named node + /// type mapped above (e.g. PHP's `primitive_type`/`cast_type` productions lex + /// the `string` scalar type-hint keyword as an unnamed token identical to the + /// named `string` literal node type). Mirrors the WASM-side + /// `astRequiresNamedNode()` guard in `ast-analysis/rules/index.ts`. + pub requires_named_node: bool, } // ── Per-language configs ───────────────────────────────────────────────────── @@ -223,6 +231,7 @@ pub const PYTHON_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['\'', '"'], string_prefixes: &['r', 'b', 'f', 'u', 'R', 'B', 'F', 'U'], + requires_named_node: false, }; pub const GO_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -233,6 +242,7 @@ pub const GO_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"', '`'], string_prefixes: &[], + requires_named_node: false, }; pub const RUST_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -243,6 +253,7 @@ pub const RUST_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const JAVA_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -253,6 +264,7 @@ pub const JAVA_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const CSHARP_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -263,6 +275,7 @@ pub const CSHARP_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const RUBY_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -273,6 +286,7 @@ pub const RUBY_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &["regex"], quote_chars: &['\'', '"'], string_prefixes: &[], + requires_named_node: false, }; pub const PHP_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -283,6 +297,7 @@ pub const PHP_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['\'', '"'], string_prefixes: &[], + requires_named_node: true, }; pub const C_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -293,6 +308,7 @@ pub const C_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const CPP_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -303,6 +319,7 @@ pub const CPP_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &['L', 'u', 'U', 'R'], + requires_named_node: false, }; /// CUDA is a C++ superset; the tree-sitter-cuda grammar extends C++ with @@ -317,6 +334,7 @@ pub const CUDA_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &['L', 'u', 'U', 'R'], + requires_named_node: false, }; pub const KOTLIN_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -327,6 +345,7 @@ pub const KOTLIN_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const SWIFT_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -337,6 +356,7 @@ pub const SWIFT_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const SCALA_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -347,6 +367,7 @@ pub const SCALA_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const BASH_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -357,6 +378,7 @@ pub const BASH_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"', '\''], string_prefixes: &[], + requires_named_node: false, }; pub const ELIXIR_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -367,6 +389,7 @@ pub const ELIXIR_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &["sigil"], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const LUA_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -377,6 +400,7 @@ pub const LUA_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['\'', '"'], string_prefixes: &[], + requires_named_node: false, }; pub const DART_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -387,6 +411,7 @@ pub const DART_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['\'', '"'], string_prefixes: &[], + requires_named_node: false, }; pub const ZIG_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -397,6 +422,7 @@ pub const ZIG_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const HASKELL_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -407,6 +433,7 @@ pub const HASKELL_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"', '\''], string_prefixes: &[], + requires_named_node: false, }; pub const OCAML_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -417,6 +444,7 @@ pub const OCAML_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; // F# string nodes in tree-sitter-fsharp surface under the `string` kind inside @@ -429,6 +457,7 @@ pub const FSHARP_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; /// Objective-C string literals use the `@"..."` prefix. The shared @@ -442,6 +471,7 @@ pub const OBJC_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const GLEAM_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -452,6 +482,7 @@ pub const GLEAM_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const JULIA_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -462,6 +493,7 @@ pub const JULIA_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const CLOJURE_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -472,6 +504,7 @@ pub const CLOJURE_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &["regex_lit"], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const ERLANG_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -482,6 +515,7 @@ pub const ERLANG_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; pub const GROOVY_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -495,6 +529,7 @@ pub const GROOVY_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['\'', '"'], string_prefixes: &[], + requires_named_node: false, }; pub const R_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -506,6 +541,7 @@ pub const R_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['\'', '"'], string_prefixes: &[], + requires_named_node: false, }; pub const SOLIDITY_AST_CONFIG: LangAstConfig = LangAstConfig { @@ -516,6 +552,7 @@ pub const SOLIDITY_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"', '\''], string_prefixes: &[], + requires_named_node: false, }; /// Verilog/SystemVerilog AST config. @@ -533,6 +570,7 @@ pub const VERILOG_AST_CONFIG: LangAstConfig = LangAstConfig { regex_types: &[], quote_chars: &['"'], string_prefixes: &[], + requires_named_node: false, }; // ── Generic AST node walker ────────────────────────────────────────────────── @@ -565,7 +603,18 @@ pub fn walk_ast_nodes_with_config( /// Classify a tree-sitter node against the language AST config. /// Returns the AST kind string if matched, or `None` to skip. -fn classify_ast_node<'a>(kind: &str, config: &'a LangAstConfig) -> Option<&'a str> { +/// +/// When `config.requires_named_node` is set, anonymous grammar tokens are +/// rejected even if their `kind()` string matches a mapped type — e.g. PHP's +/// `primitive_type`/`cast_type` productions lex the `string` scalar +/// type-hint keyword as an unnamed token whose `kind()` collides with the +/// named `string` literal node (#1821, mirrors TypeScript's `predefined_type` +/// guard from #1729). +fn classify_ast_node<'a>(node: &Node, config: &'a LangAstConfig) -> Option<&'a str> { + if config.requires_named_node && !node.is_named() { + return None; + } + let kind = node.kind(); if config.new_types.contains(&kind) { Some("new") } else if config.throw_types.contains(&kind) { @@ -673,7 +722,7 @@ fn walk_ast_nodes_with_config_depth( return; } - if let Some(ast_kind) = classify_ast_node(node.kind(), config) { + if let Some(ast_kind) = classify_ast_node(node, config) { match ast_kind { "new" => { ast_nodes.push(build_new_node(node, source)); diff --git a/crates/codegraph-core/src/extractors/java.rs b/crates/codegraph-core/src/extractors/java.rs index 726db2bf9..d9547737d 100644 --- a/crates/codegraph-core/src/extractors/java.rs +++ b/crates/codegraph-core/src/extractors/java.rs @@ -101,6 +101,7 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); // Superclass @@ -155,6 +156,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -170,6 +172,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: compute_all_metrics(&child, source, "java"), cfg: build_function_cfg(&child, "java", source), children: None, + bodyless: Some(child.child_by_field_name("body").is_none()), }); } } @@ -189,6 +192,7 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -221,6 +225,7 @@ fn handle_method_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "java"), cfg: build_function_cfg(node, "java", source), children: opt_children(children), + bodyless: Some(node.child_by_field_name("body").is_none()), }); } } @@ -243,6 +248,7 @@ fn handle_constructor_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: compute_all_metrics(node, source, "java"), cfg: build_function_cfg(node, "java", source), children: opt_children(children), + bodyless: None, }); } } @@ -524,6 +530,42 @@ mod tests { assert_eq!(children[0].name, "x"); assert_eq!(children[0].kind, "parameter"); assert_eq!(children[1].name, "y"); + // A real, bodied method — a dotted name alone must never be treated as a + // signature-only stub (#1922). + assert_ne!(bar.bodyless, Some(true)); + } + + /// Regression test for #1922: an interface method declaration (no body) must + /// be marked `bodyless`; an abstract class method (also no body, but reached + /// via the generic `handle_method_decl` path, not the interface-specific one) + /// must be marked `bodyless` too — the same dotted-name shape as a real + /// method must not be enough to tell them apart. + #[test] + fn interface_and_abstract_methods_are_bodyless_concrete_methods_are_not() { + let iface = parse_java("interface Repo { boolean save(String id, int value); }"); + let iface_save = iface + .definitions + .iter() + .find(|d| d.name == "Repo.save") + .unwrap(); + assert_eq!(iface_save.bodyless, Some(true)); + + let abstract_cls = + parse_java("abstract class Repo { abstract boolean save(String id, int value); }"); + let abstract_save = abstract_cls + .definitions + .iter() + .find(|d| d.name == "Repo.save") + .unwrap(); + assert_eq!(abstract_save.bodyless, Some(true)); + + let concrete = parse_java("class Repo { boolean save(String id, int value) { return true; } }"); + let concrete_save = concrete + .definitions + .iter() + .find(|d| d.name == "Repo.save") + .unwrap(); + assert_ne!(concrete_save.bodyless, Some(true)); } #[test] diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 5a9292c7e..1ee3603c2 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -3,6 +3,7 @@ use super::SymbolExtractor; use crate::ast_analysis::cfg::build_function_cfg; use crate::ast_analysis::complexity::compute_all_metrics; use crate::types::*; +use std::collections::{HashMap, HashSet}; use tree_sitter::{Node, Tree}; /// Well-known JS globals that must not be recorded as pts targets. @@ -30,14 +31,13 @@ pub struct JsExtractor; impl SymbolExtractor for JsExtractor { fn extract(&self, tree: &Tree, source: &[u8], file_path: &str) -> FileSymbols { let mut symbols = FileSymbols::new(file_path.to_string()); - walk_tree(&tree.root_node(), source, &mut symbols, match_js_node); - // Emit qualified `obj.method(function)` definitions for object-literal shorthand - // methods AFTER match_js_node so that the bare `f(method)` node created by - // handle_method_def comes first in definitions — matching WASM ordering where - // handleMethodCapture (query path) runs before extractObjectLiteralFunctions - // (runCollectorWalk). Equal-span tie-break in findCaller keeps the first entry, - // so bare `f(method)` wins for call attribution in both engines. - walk_tree(&tree.root_node(), source, &mut symbols, match_js_objlit_qualified_method_defs); + // Issue #1845: collected once up front so identifier-argument calls to + // same-file user-defined higher-order functions can be recognized + // during the single forward walk below, regardless of declaration order. + let callback_param_shapes = collect_callback_param_shapes(&tree.root_node(), source); + walk_tree(&tree.root_node(), source, &mut symbols, |node, source, symbols, depth| { + match_js_node(node, source, symbols, depth, &callback_param_shapes) + }); walk_ast_nodes(&tree.root_node(), source, &mut symbols.ast_nodes); walk_tree(&tree.root_node(), source, &mut symbols, match_js_type_map); walk_tree(&tree.root_node(), source, &mut symbols, match_js_return_type_map); @@ -45,6 +45,15 @@ impl SymbolExtractor for JsExtractor { walk_tree(&tree.root_node(), source, &mut symbols, match_js_prototype_methods); // call_assignments runs after type_map is populated (needs receiver types) walk_tree(&tree.root_node(), source, &mut symbols, match_js_call_assignments); + // #1893: same-file get/set accessor property reads/writes → calls edges. + // Runs after type_map is populated (needs receiver types for the + // `varName.prop` case) and after handle_method_def has run (the + // registry re-derives accessor names directly from the AST, so source + // order relative to match_js_node doesn't matter for correctness). + let local_accessors = collect_local_accessors(&tree.root_node(), source); + walk_tree(&tree.root_node(), source, &mut symbols, |node, source, symbols, _depth| { + handle_accessor_property_read(node, source, symbols, &local_accessors) + }); // Phase 8.3c–8.3f: points-to bindings (params, this-rebinding, arrays, // spread, for-of, object rest/props) for the pts constraint solver. walk_tree(&tree.root_node(), source, &mut symbols, match_js_pts_bindings); @@ -399,12 +408,7 @@ fn seed_object_create_entries(var_name: &str, call_node: &Node, source: &[u8], s let Some(key_n) = child.child_by_field_name("key") else { continue }; let Some(val_n) = child.child_by_field_name("value") else { continue }; if val_n.kind() != "identifier" { continue; } - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; + let Some(key) = resolve_pair_key_name(&key_n, source) else { continue }; symbols.type_map.push(TypeMapEntry { name: format!("{}.{}", var_name, key), type_name: node_text(&val_n, source).to_string(), @@ -423,12 +427,7 @@ fn seed_descriptor_object(obj_name: &str, obj_node: &Node, source: &[u8], symbol if child.kind() != "pair" { continue; } let Some(key_n) = child.child_by_field_name("key") else { continue }; let Some(val_n) = child.child_by_field_name("value") else { continue }; - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; + let Some(key) = resolve_pair_key_name(&key_n, source) else { continue }; let Some(target) = find_descriptor_value(&val_n, source) else { continue }; symbols.type_map.push(TypeMapEntry { name: format!("{}.{}", obj_name, key), @@ -480,6 +479,39 @@ fn find_descriptor_accessors<'a>(node: &Node<'a>, source: &'a [u8]) -> Vec<&'a s result } +/// True when `declarator` is the shape `extract_object_literal_functions` qualifies: a plain +/// identifier name, outside any function scope. Mirrors TS `isEligibleObjectLiteralDeclarator`. +fn is_eligible_object_literal_declarator(declarator: &Node) -> bool { + if declarator.kind() != "variable_declarator" { return false; } + let Some(name_n) = declarator.child_by_field_name("name") else { return false }; + if name_n.kind() != "identifier" { return false; } + find_parent_of_types(declarator, &[ + "function_declaration", "arrow_function", "function_expression", + "method_definition", "generator_function_declaration", "generator_function", + ]).is_none() +} + +/// True when `method_node` (a method_definition) is a shorthand method whose enclosing object +/// literal is the direct value of an eligible variable declarator (see +/// `is_eligible_object_literal_declarator`) AND has no enclosing class — the common shape +/// `extract_object_literal_functions` already emits both the qualified (`varName.method`) and +/// bare (`method`) definitions for, together, in source position order relative to the +/// declaration itself. `handle_method_def` skips these nodes to avoid pushing a second, +/// differently-positioned bare entry that makes native and WASM disagree on `definitions` +/// array order (#1818). Mirrors TS `isObjectLiteralDeclaratorMethod`. +/// +/// The enclosing-class check excludes a rarer, unrelated nested shape — e.g. a const declared +/// inside a class `static { }` block (not itself function-scoped) — where `handle_method_def` +/// already produces a *class*-qualified entry (`ClassName.method`, via `find_parent_class`) +/// rather than a bare one; that entry must be left alone, not duplicated by a spurious bare push. +fn is_object_literal_declarator_method(method_node: &Node, source: &[u8]) -> bool { + let Some(obj) = method_node.parent() else { return false }; + if obj.kind() != "object" { return false; } + let Some(declarator) = obj.parent() else { return false }; + if !is_eligible_object_literal_declarator(&declarator) { return false; } + find_parent_class(method_node, source).is_none() +} + /// Phase 8.3f: extract function/arrow properties from an object literal as standalone definitions /// and seed composite typeMap keys so that `this.method()` inside Object.defineProperty accessors /// can resolve them. @@ -492,6 +524,15 @@ fn find_descriptor_accessors<'a>(node: &Node<'a>, source: &'a [u8]) -> Vec<&'a s /// `const obj = { baz: () => {} }` → Definition { name: "obj.baz", kind: "function" } /// + TypeMapEntry { name: "obj.baz", type_name: "obj.baz" } /// `const obj = { baz }` (shorthand) → TypeMapEntry { name: "obj.baz", type_name: "baz" } +/// +/// Called for ALL declaration kinds (`const`, `let`, `var`) — see `handle_var_decl`'s two call +/// sites. For `method_definition` children (shorthand methods), also emits the bare, unqualified +/// `Definition { name: "baz", kind: "method" }` that `handle_method_def` would otherwise produce +/// on its own — see `is_object_literal_declarator_method`, which it skips for exactly these +/// nodes so both entries are always emitted here together, in a fixed relative order (bare +/// first, matching the `findCaller` equal-span tie-break WASM relies on). Keeping them adjacent +/// (rather than one inline and one from a separate deferred pass) is what keeps native and WASM +/// agreeing on `definitions` array order (#1818). fn extract_object_literal_functions( obj_node: &Node, source: &[u8], @@ -512,12 +553,10 @@ fn extract_object_literal_functions( "pair" => { let Some(key_n) = child.child_by_field_name("key") else { continue }; let Some(val_n) = child.child_by_field_name("value") else { continue }; - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; + // Use resolve_pair_key_name to strip brackets from computed string keys + // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]), + // mirroring resolve_method_def_name below. + let Some(key) = resolve_pair_key_name(&key_n, source) else { continue }; let qualified = format!("{}.{}", var_name, key); match val_n.kind() { "arrow_function" | "function_expression" | "function" => { @@ -532,6 +571,7 @@ fn extract_object_literal_functions( complexity: compute_all_metrics(&val_n, source, "javascript"), cfg: build_function_cfg(&val_n, "javascript", source), children: None, + bodyless: None, }); // Store qualified name as value so resolver looks up the qualified def. symbols.type_map.push(TypeMapEntry { @@ -552,10 +592,6 @@ fn extract_object_literal_functions( } } "method_definition" => { - // The definition (`obj.baz(function)`) is emitted by the second-pass - // `match_js_objlit_qualified_method_defs` walker (runs after `match_js_node`) - // so that `handle_method_def`'s bare `baz(method)` node appears first in - // `definitions`. Only seed the typeMap entry here. // Use resolve_method_def_name to strip brackets from computed string keys // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]). let Some(method_name) = resolve_method_def_name(&child, source) else { continue }; @@ -563,10 +599,41 @@ fn extract_object_literal_functions( // typeMap['obj.baz'] = 'baz' — points to the bare-name definition so // the two-step accessor dispatch resolves via the bare node. symbols.type_map.push(TypeMapEntry { - name: qualified, - type_name: method_name, + name: qualified.clone(), + type_name: method_name.clone(), confidence: 0.85, }); + // Bare entry (when handle_method_def would have produced one — see + // is_object_literal_declarator_method) then the qualified entry, adjacent — + // matches WASM's extractObjectLiteralFunctions and keeps native/WASM + // `definitions` array order aligned (#1818). When there's an enclosing class, + // handle_method_def already pushes a class-qualified entry on its own. + if is_object_literal_declarator_method(&child, source) { + let children = extract_js_parameters(&child, source); + symbols.definitions.push(Definition { + name: method_name, + kind: "method".to_string(), + line: start_line(&child), + end_line: Some(end_line(&child)), + decorators: None, + complexity: compute_all_metrics(&child, source, "javascript"), + cfg: build_function_cfg(&child, "javascript", source), + children: opt_children(children), + bodyless: None, + }); + } + let body = child.child_by_field_name("body"); + symbols.definitions.push(Definition { + name: qualified, + kind: "function".to_string(), + line: start_line(&child), + end_line: Some(end_line(&child)), + decorators: None, + complexity: body.and_then(|b| compute_all_metrics(&b, source, "javascript")), + cfg: body.and_then(|b| build_function_cfg(&b, "javascript", source)), + children: None, + bodyless: None, + }); } _ => {} } @@ -597,20 +664,15 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], "pair" => { let Some(key_n) = child.child_by_field_name("key") else { continue }; let Some(val_n) = child.child_by_field_name("value") else { continue }; - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; + let Some(key) = resolve_pair_key_name(&key_n, source) else { continue }; let qualified = format!("{}.{}", var_name, key); match val_n.kind() { "arrow_function" | "function_expression" | "function" => { // Store qualified name as value so the resolver finds the qualified def. // Mirrors WASM: setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85). - // For `const`, `extract_object_literal_functions` creates the matching definition. - // For `let`/`var`, `match_js_objlit_qualified_method_defs` creates it in its - // deferred second pass (now covers all declaration kinds, not just `const`). + // `extract_object_literal_functions` creates the matching definition + // inline for all declaration kinds (`const`, `let`, `var`) — see + // `handle_var_decl`'s two call sites. symbols.type_map.push(TypeMapEntry { name: qualified.clone(), type_name: qualified, @@ -631,11 +693,10 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], "method_definition" => { // Method shorthand: `let obj = { baz() {} }` → typeMap['obj.baz'] = 'baz' // Points to the bare-name definition so the two-step accessor dispatch resolves - // via the bare node. `handle_method_def` always creates a bare definition for - // method_definition nodes; `match_js_objlit_qualified_method_defs` (which now - // covers all declaration kinds) adds the qualified definition in its deferred - // second pass. Using the bare name here keeps resolution consistent across all - // declaration kinds (const/let/var). + // via the bare node. `extract_object_literal_functions` creates both the bare + // and qualified definitions inline for all declaration kinds (const/let/var) — + // see `handle_var_decl`'s two call sites. Using the bare name here keeps + // resolution consistent across all declaration kinds. let Some(method_name) = resolve_method_def_name(&child, source) else { continue }; let qualified = format!("{}.{}", var_name, method_name); symbols.type_map.push(TypeMapEntry { @@ -649,103 +710,6 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], } } -/// Second-pass walker: emit qualified `obj.method(function)` definitions for -/// `method_definition` and (for `let`/`var`) `pair+arrow/function` children of object literals. -/// -/// **method_definition** (all declaration kinds — `const`, `let`, `var`): -/// This must run AFTER the main `match_js_node` walk so that the bare `f(method)` node -/// created by `handle_method_def` appears BEFORE the qualified `obj.f(function)` node -/// in `symbols.definitions`. `findCaller` picks the narrowest-span enclosing definition; -/// when spans are equal it keeps the first inserted one (strict `<`), so `f(method)` wins -/// and call-edge attribution matches WASM (which runs `handleMethodCapture` via the query -/// path before `extractObjectLiteralFunctions` via `runCollectorWalk`). -/// -/// **pair + arrow_function / function_expression / function** (`let`/`var` only): -/// For `const`, `extract_object_literal_functions` already creates the qualified definition; -/// repeating it here would produce a duplicate. For `let`/`var`, no other pass emits the -/// qualified definition, so we must emit it here. Without the definition, the typeMap entry -/// seeded by `seed_objlit_type_map_entries` (`"api.save" → "api.save"`) dead-ends: the -/// resolver finds the typeMap entry but then fails to locate a node named `"api.save"`. -/// -/// WASM produces both nodes — the qualified one via `extractObjectLiteralFunctions` and the -/// bare one via `handleMethodCapture`. This pass mirrors that by adding only the qualified -/// definitions, deferred so ordering is correct. -fn match_js_objlit_qualified_method_defs( - node: &Node, - source: &[u8], - symbols: &mut FileSymbols, - _depth: usize, -) { - // Only lexical/variable declarations at non-function scope. - if !matches!(node.kind(), "lexical_declaration" | "variable_declaration") { return; } - if find_parent_of_types(node, &[ - "function_declaration", "arrow_function", "function_expression", - "method_definition", "generator_function_declaration", "generator_function", - ]).is_some() { - return; - } - let is_const = node.child(0).map(|c| node_text(&c, source) == "const").unwrap_or(false); - for i in 0..node.child_count() { - let Some(declarator) = node.child(i) else { continue }; - if declarator.kind() != "variable_declarator" { continue; } - let Some(name_n) = declarator.child_by_field_name("name") else { continue }; - let Some(value_n) = declarator.child_by_field_name("value") else { continue }; - if value_n.kind() != "object" || name_n.kind() != "identifier" { continue; } - let var_name = node_text(&name_n, source); - for j in 0..value_n.child_count() { - let Some(child) = value_n.child(j) else { continue }; - match child.kind() { - "method_definition" => { - // Emit qualified definition for ALL declaration kinds. - // Use resolve_method_def_name to strip brackets from computed string keys - // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]). - let Some(method_name) = resolve_method_def_name(&child, source) else { continue }; - let qualified = format!("{}.{}", var_name, method_name); - let body = child.child_by_field_name("body"); - symbols.definitions.push(Definition { - name: qualified, - kind: "function".to_string(), - line: start_line(&child), - end_line: Some(end_line(&child)), - decorators: None, - complexity: body.and_then(|b| compute_all_metrics(&b, source, "javascript")), - cfg: body.and_then(|b| build_function_cfg(&b, "javascript", source)), - children: None, - }); - } - "pair" if !is_const => { - // Emit qualified definition for `let`/`var` pair+arrow/function values only. - // For `const`, `extract_object_literal_functions` already creates this definition; - // creating it again here would be a duplicate. - let Some(key_n) = child.child_by_field_name("key") else { continue }; - let Some(val_n) = child.child_by_field_name("value") else { continue }; - if !matches!(val_n.kind(), "arrow_function" | "function_expression" | "function") { - continue; - } - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; - let qualified = format!("{}.{}", var_name, key); - symbols.definitions.push(Definition { - name: qualified, - kind: "function".to_string(), - line: start_line(&child), - end_line: Some(end_line(&val_n)), - decorators: None, - complexity: compute_all_metrics(&val_n, source, "javascript"), - cfg: build_function_cfg(&val_n, "javascript", source), - children: None, - }); - } - _ => {} - } - } - } -} - // ── Return-type map extraction (Phase 8.2 parity) ─────────────────────────── /// Walk the AST collecting function/method return types into `symbols.return_type_map`. @@ -930,6 +894,7 @@ fn handle_js_prototype_assignment(lhs: &Node, rhs: &Node, source: &[u8], symbols complexity: compute_all_metrics(rhs, source, "javascript"), cfg: build_function_cfg(rhs, "javascript", source), children: opt_children(children), + bodyless: None, }); } } @@ -951,6 +916,7 @@ fn emit_js_prototype_method(class_name: &str, method_name: &str, rhs: &Node, sou complexity: compute_all_metrics(rhs, source, "javascript"), cfg: build_function_cfg(rhs, "javascript", source), children: opt_children(children), + bodyless: None, }); } "identifier" => { @@ -982,6 +948,7 @@ fn extract_js_prototype_object_literal(class_name: &str, obj_node: &Node, source complexity: compute_all_metrics(&child, source, "javascript"), cfg: build_function_cfg(&child, "javascript", source), children: opt_children(children), + bodyless: None, }); } "shorthand_property_identifier" => { @@ -998,19 +965,9 @@ fn extract_js_prototype_object_literal(class_name: &str, obj_node: &Node, source let key_node = child.child_by_field_name("key"); let value_node = child.child_by_field_name("value"); if let (Some(key_node), Some(value_node)) = (key_node, value_node) { - let method_name: &str = if key_node.kind() == "string" { - let s = node_text(&key_node, source); - // Strip exactly one matching pair of surrounding quote characters. - // `trim_matches` would also strip embedded quotes; we only want the - // outermost delimiter pair so `"it's"` stays `it's`. - s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) - .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) - .unwrap_or(s) - } else { - node_text(&key_node, source) - }; + let Some(method_name) = resolve_pair_key_name(&key_node, source) else { continue }; if method_name.is_empty() { continue; } - emit_js_prototype_method(class_name, method_name, &value_node, source, symbols); + emit_js_prototype_method(class_name, &method_name, &value_node, source, symbols); } } _ => {} @@ -1058,7 +1015,13 @@ fn match_js_call_assignments(node: &Node, source: &[u8], symbols: &mut FileSymbo } } -fn match_js_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { +fn match_js_node( + node: &Node, + source: &[u8], + symbols: &mut FileSymbols, + _depth: usize, + callback_param_shapes: &CallbackParamShapes, +) { match node.kind() { "function_declaration" | "generator_function_declaration" => handle_function_decl(node, source, symbols), "class_declaration" | "abstract_class_declaration" @@ -1073,12 +1036,20 @@ fn match_js_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: "type_alias_declaration" => handle_type_alias(node, source, symbols), "enum_declaration" => handle_enum_decl(node, source, symbols), "lexical_declaration" | "variable_declaration" => handle_var_decl(node, source, symbols), - "call_expression" => handle_call_expr(node, source, symbols), + "call_expression" => handle_call_expr(node, source, symbols, callback_param_shapes), "new_expression" => handle_new_expr(node, source, symbols), "decorator" => handle_decorator(node, source, symbols), "import_statement" => handle_import_stmt(node, source, symbols), "export_statement" => handle_export_stmt(node, source, symbols), "expression_statement" => handle_expr_stmt(node, source, symbols), + // #1771: dispatch-table-style object-literal property values + // (`{ resolve: someFunction }` / shorthand `{ someFunction }`). + "pair" => handle_object_literal_pair_value_ref(node, source, &mut symbols.calls), + "shorthand_property_identifier" => { + handle_object_literal_shorthand_value_ref(node, source, &mut symbols.calls) + } + // #1784: `instanceof ClassName` checks, e.g. `err instanceof CodegraphError`. + "binary_expression" => handle_instanceof_value_ref(node, source, &mut symbols.calls), _ => {} } } @@ -1097,6 +1068,7 @@ fn handle_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "javascript"), cfg: build_function_cfg(node, "javascript", source), children: opt_children(children), + bodyless: None, }); } } @@ -1114,6 +1086,7 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); // Heritage: extends + implements @@ -1140,6 +1113,27 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } +/// Unwrap a `computed_property_name` node (e.g. `['foo']`) to its inner string-literal text +/// with quotes stripped, or `None` when the computed key isn't a plain string literal (e.g. +/// `[Symbol.iterator]`, `[x]`) — there's no statically resolvable name in that case. +fn resolve_computed_key_name(computed_node: &Node, source: &[u8]) -> Option { + // child(0)='[', child(1)=inner expression, child(2)=']' + let inner = computed_node.child(1)?; + match inner.kind() { + "string" => { + let s = extract_string_fragment(&inner, source).unwrap_or(""); + if s.is_empty() { return None; } + Some(s.to_string()) + } + "string_fragment" => { + let s = node_text(&inner, source); + if s.is_empty() { return None; } + Some(s.to_string()) + } + _ => None, // non-string computed key — skip + } +} + /// Extract the plain method name from a `method_definition` node. /// /// For computed property names (`['methodName']`), strips brackets and quotes from @@ -1149,28 +1143,35 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { fn resolve_method_def_name(node: &Node, source: &[u8]) -> Option { let name_node = node.child_by_field_name("name")?; if name_node.kind() == "computed_property_name" { - // child(0)='[', child(1)=string literal, child(2)=']' - let inner = name_node.child(1)?; - match inner.kind() { - "string" => { - let s = extract_string_fragment(&inner, source).unwrap_or(""); - if s.is_empty() { return None; } - Some(s.to_string()) - } - "string_fragment" => { - let s = node_text(&inner, source); - if s.is_empty() { return None; } - Some(s.to_string()) - } - _ => None, // non-string computed key — skip - } + resolve_computed_key_name(&name_node, source) } else { Some(node_text(&name_node, source).to_string()) } } +/// Resolve an object-literal `pair` node's key field to its plain string form. +/// +/// Mirrors `resolve_method_def_name`'s computed-key handling so `{ ['foo']: () => {} }` and +/// `{ ['foo']() {} }` resolve identically: quoted string keys have their quotes stripped, +/// computed string-literal keys (`['foo']`) are unwrapped, and non-string computed keys +/// (e.g. `[Symbol.iterator]`) return `None` (no resolvable name — caller skips the pair) +/// rather than falling back to the raw bracket/quote source text. +fn resolve_pair_key_name(key_n: &Node, source: &[u8]) -> Option { + match key_n.kind() { + "string" => extract_string_fragment(key_n, source).map(|s| s.to_string()), + "computed_property_name" => resolve_computed_key_name(key_n, source), + _ => Some(node_text(key_n, source).to_string()), + } +} + fn handle_method_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(method_name) = resolve_method_def_name(node, source) { + // extract_object_literal_functions already emits this node's bare + qualified + // definitions together (#1818) — skip here to avoid a duplicate, differently- + // positioned bare entry. + if is_object_literal_declarator_method(node, source) { + return; + } let method_name = method_name.as_str(); let parent_class = find_parent_class(node, source); let full_name = match parent_class { @@ -1187,10 +1188,176 @@ fn handle_method_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "javascript"), cfg: build_function_cfg(node, "javascript", source), children: opt_children(children), + bodyless: None, }); } } +// ── ES6 getter/setter property-read call attribution (#1893) ──────────────── +// +// A bare (non-call) property read/write on an ES6 `get`/`set` class accessor +// (`obj.isReady`, no call parens) invokes the accessor function just as surely +// as `obj.isReady()` would if written explicitly — but call-site extraction +// only ever looked at `member_expression` nodes used as a call_expression's +// callee, so accessor reads/writes never produced a `calls` edge at all. +// Mirrors `collectAccessorPropertyRead` in `src/extractors/javascript.ts`. +// +// Scoped to the *same-file* case: `this.prop` inside one of the accessor's own +// class's methods, or `varName.prop` where `varName`'s type (from this file's +// own type_map) is a class also declared in this file. Cross-file accessor +// reads (the accessor's class declared in a different file than the read +// site) are not yet covered — see #2030. + +/// Per-property record of which accessor kinds a same-file class declares. +#[derive(Default, Clone, Copy)] +struct LocalAccessorInfo { + get: bool, + set: bool, +} + +/// `ClassName.propName` → which accessor kinds are declared, for this file only. +type LocalAccessorRegistry = HashMap; + +/// True when `meth_node` (a method_definition) carries a `get` or `set` +/// accessor modifier — an unnamed token child preceding the `name` field +/// (tree-sitter represents `get`/`set`/`static`/`async` as literal unnamed +/// children, not a dedicated field). Returns `None` for a plain method. +fn get_method_accessor_kind(meth_node: &Node) -> Option<&'static str> { + let name_node = meth_node.child_by_field_name("name"); + for i in 0..meth_node.child_count() { + let Some(child) = meth_node.child(i) else { continue }; + if Some(child.id()) == name_node.map(|n| n.id()) { + break; + } + match child.kind() { + "get" => return Some("get"), + "set" => return Some("set"), + _ => {} + } + } + None +} + +/// Pre-scan pass: collect every ES6 get/set class-accessor declared in this +/// file, keyed by its qualified `ClassName.propName` name — the same +/// qualification `handle_method_def` already gives the accessor's own +/// Definition entry. Must run before the property-read pass so the registry +/// is complete regardless of source order. +fn collect_local_accessors(root: &Node, source: &[u8]) -> LocalAccessorRegistry { + let mut registry = LocalAccessorRegistry::new(); + + fn walk(node: &Node, source: &[u8], registry: &mut LocalAccessorRegistry, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + if node.kind() == "method_definition" { + if let Some(kind) = get_method_accessor_kind(node) { + if let (Some(class_name), Some(prop_name)) = + (find_parent_class(node, source), resolve_method_def_name(node, source)) + { + let key = format!("{}.{}", class_name, prop_name); + let entry = registry.entry(key).or_default(); + if kind == "get" { + entry.get = true; + } else { + entry.set = true; + } + } + } + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, source, registry, depth + 1); + } + } + } + + walk(root, source, &mut registry, 0); + registry +} + +/// Detect a bare (non-call) `this.prop` / `varName.prop` member-expression +/// that reads or writes a same-file accessor property, and record it as an +/// ordinary `Call` — indistinguishable from a real +/// `this.prop()`/`varName.prop()` call site, so it flows through the existing +/// (unchanged) call-resolution cascade. +/// +/// A plain assignment (`obj.prop = value`) invokes the setter; every other +/// bare usage (reads, compound-assignment targets, etc.) invokes the getter. +/// When a property declares *both* a getter and a setter, the two accessors +/// share the same qualified name and resolution has no way to tell them +/// apart — rather than risk an edge to the wrong one, that case is skipped +/// entirely (mirrors the "ambiguous → drop rather than fan out" precedent +/// used elsewhere in call resolution). +fn handle_accessor_property_read( + node: &Node, + source: &[u8], + symbols: &mut FileSymbols, + local_accessors: &LocalAccessorRegistry, +) { + if node.kind() != "member_expression" { + return; + } + // obj.method() — already a real call, handled by the regular call path + // regardless of whether `method` also happens to be an accessor. + if let Some(parent) = node.parent() { + if parent.kind() == "call_expression" + && parent.child_by_field_name("function").map(|f| f.id()) == Some(node.id()) + { + return; + } + } + + let Some(obj) = node.child_by_field_name("object") else { return }; + let Some(prop_node) = node.child_by_field_name("property") else { return }; + if prop_node.kind() != "property_identifier" { + return; + } + let prop_name = node_text(&prop_node, source); + + let (receiver, class_name): (String, Option) = match obj.kind() { + "this" => ("this".to_string(), find_parent_class(node, source)), + "identifier" => { + let obj_name = node_text(&obj, source).to_string(); + let type_name = symbols + .type_map + .iter() + .find(|e| e.name == obj_name) + .map(|e| e.type_name.clone()); + (obj_name, type_name) + } + _ => return, + }; + let Some(class_name) = class_name else { return }; + + let key = format!("{}.{}", class_name, prop_name); + let Some(accessor_info) = local_accessors.get(&key) else { return }; + if accessor_info.get && accessor_info.set { + return; + } + + let is_plain_assign_target = node + .parent() + .filter(|p| p.kind() == "assignment_expression") + .and_then(|p| p.child_by_field_name("left")) + .map(|l| l.id()) + == Some(node.id()); + let needed_get = !is_plain_assign_target; + if needed_get && !accessor_info.get { + return; + } + if !needed_get && !accessor_info.set { + return; + } + + symbols.calls.push(Call { + name: prop_name.to_string(), + line: start_line(node), + receiver: Some(receiver), + ..Default::default() + }); +} + /// Create a synthetic `ClassName.` definition for a class static block /// so that calls inside the block are attributed to a method-kind node and /// `super.method()` dispatch can walk up to the parent class. @@ -1211,6 +1378,7 @@ fn handle_static_block(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -1249,6 +1417,7 @@ fn handle_field_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -1264,6 +1433,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); // Extract interface methods let body = node @@ -1286,6 +1456,7 @@ fn handle_type_alias(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -1303,10 +1474,19 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } +/// Node types marking a function-body scope; declarations inside these are skipped by +/// the top-level-constant/destructuring branches below (parity with TS `FUNCTION_SCOPE_TYPES`). +const VAR_DECL_FN_SCOPE_TYPES: [&str; 6] = [ + "function_declaration", "arrow_function", + "function_expression", "method_definition", + "generator_function_declaration", "generator_function", +]; + fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let is_const = node.child(0) .map(|c| node_text(&c, source) == "const") @@ -1318,6 +1498,8 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let value_n = declarator.child_by_field_name("value"); let (Some(name_n), Some(value_n)) = (name_n, value_n) else { continue }; let vt = value_n.kind(); + let in_function_scope = find_parent_of_types(node, &VAR_DECL_FN_SCOPE_TYPES).is_some(); + if vt == "arrow_function" || vt == "function_expression" || vt == "function" || vt == "generator_function" { let children = extract_js_parameters(&value_n, source); symbols.definitions.push(Definition { @@ -1329,14 +1511,9 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(&value_n, source, "javascript"), cfg: build_function_cfg(&value_n, "javascript", source), children: opt_children(children), + bodyless: None, }); - } else if is_const && name_n.kind() == "object_pattern" - && find_parent_of_types(node, &[ - "function_declaration", "arrow_function", - "function_expression", "method_definition", - "generator_function_declaration", "generator_function", - ]).is_none() - { + } else if is_const && name_n.kind() == "object_pattern" && !in_function_scope { // Parity with TS query path (extractDestructuredBindingsWalk): // skip destructured const bindings inside function scopes so the // Rust walk path matches FUNCTION_SCOPE_TYPES behaviour. @@ -1353,7 +1530,13 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(str_arg) = find_child(&args, "string") { let mod_path = node_text(&str_arg, source) .replace(&['\'', '"'][..], ""); - let names = collect_object_pattern_names(&name_n, source); + // CJS require bindings never populate renamed_imports — + // resolve_call_targets deliberately ignores the original + // name for these (empty target_file forces a same-file + // fallback match, matching WASM's importedNamesMap + // exclusion, #1678) — so the rename pairs collected here + // are discarded. + let names = collect_object_pattern_names(&name_n, source, &mut Vec::new()); if !names.is_empty() { let mut imp = Import::new( mod_path, names, start_line(node), @@ -1366,13 +1549,11 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } } - } else if is_const && is_js_literal(&value_n) - && find_parent_of_types(node, &[ - "function_declaration", "arrow_function", - "function_expression", "method_definition", - "generator_function_declaration", "generator_function", - ]).is_none() - { + } else if is_const && name_n.kind() == "identifier" && !in_function_scope { + // Any other initializer shape becomes a "constant" Definition, regardless of + // complexity (call/member/parenthesized expressions, etc.) — mirroring how + // function declarations are captured regardless of body complexity, and the + // WASM/TS extractor's unconditional identifier branch (#1819). symbols.definitions.push(Definition { name: node_text(&name_n, source).to_string(), kind: "constant".to_string(), @@ -1382,15 +1563,35 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); // Phase 8.3f: extract function/arrow properties from object literals and seed // typeMap composite keys so that this.method() inside Object.defineProperty // accessor functions can resolve them. - if value_n.kind() == "object" && name_n.kind() == "identifier" { + if value_n.kind() == "object" { let var_name = node_text(&name_n, source); extract_object_literal_functions(&value_n, source, var_name, symbols); } - } else if name_n.kind() == "identifier" && value_n.kind() == "identifier" { + } else if is_const && name_n.kind() == "array_pattern" && !in_function_scope { + // Array destructuring: `const [x, y] = ...` — one constant Definition per + // bound identifier (#1901). Scope guard mirrors the object_pattern branch above. + extract_array_pattern_bindings(&name_n, source, start_line(node), end_line(node), &mut symbols.definitions); + } else if !is_const && value_n.kind() == "object" && name_n.kind() == "identifier" && !in_function_scope { + // `let`/`var` object literals get no "constant" definition of their own (mirrors + // WASM extractLetVarObjLiteralDeclarators) but still need their function/method + // properties extracted — inline here, like the `const` branch above, so native and + // WASM agree on `definitions` array order (#1818). Previously deferred to a + // separate post-walk pass that ran after the whole file, which put these qualified + // definitions in the wrong relative position. + let var_name = node_text(&name_n, source); + extract_object_literal_functions(&value_n, source, var_name, symbols); + } + + // pts fn_ref_binding tracking runs independently of the Definition-shape branching + // above (mirrors WASM's collectFnRefBindings, which always runs before any + // Definition-related early return) so `const alias = handler` still seeds a pts + // alias even though `alias` now also gets its own "constant" Definition (#1819). + if name_n.kind() == "identifier" && value_n.kind() == "identifier" { // Phase 8.3: `const alias = handler` — record for pts analysis. // Mirror the JS BUILTIN_GLOBALS guard: skip well-known JS globals so // they are never seeded as pts targets (e.g. `const a = Array`). @@ -1539,23 +1740,40 @@ fn extract_dispatch_table_call( }) } -fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { +fn handle_call_expr( + node: &Node, + source: &[u8], + symbols: &mut FileSymbols, + callback_param_shapes: &CallbackParamShapes, +) { let Some(fn_node) = node.child_by_field_name("function") else { return }; if fn_node.kind() == "import" { handle_dynamic_import(node, &fn_node, source, symbols); return; } - // `this(args)` and `super(args)` — the callee is `this`/`super` used as a - // function, not a named identifier. The `this` call record is emitted by + // `this(args)` — the callee is `this` used as a function, not a named + // identifier. The `this` call record is emitted by // collect_this_call_and_bindings (called from match_js_pts_bindings). - // Neither case should emit callback-reference calls for the arguments, because - // those arguments are values passed *to* the rebound function — not callbacks - // of the enclosing scope. Without this guard, identifier arguments like `b` - // in `this(b)` or `a` in `super(a)` become spurious dynamic calls that the - // pts resolver resolves to globally-defined functions with the same name in - // other files, producing false cross-file call edges. - // Mirrors the early-return guard in the TS handleCallExpr (javascript.ts:1135). - if fn_node.kind() == "this" || fn_node.kind() == "super" { + // Callback-reference-call extraction is skipped for the arguments, because + // those arguments are values passed *to* the rebound function — not + // callbacks of the enclosing scope. Without this guard, an identifier + // argument like `b` in `this(b)` becomes a spurious dynamic call that the + // pts resolver resolves to a globally-defined function with the same name + // in another file, producing a false cross-file call edge. + if fn_node.kind() == "this" { + return; + } + // Bare `super(args)` — invokes the parent class's constructor. Modeled as + // a `constructor` call with receiver `super` (mirrors the `super` branch + // in extractCallInfo, src/extractors/javascript.ts) so it flows through + // the same this/super hierarchy dispatch that already resolves + // `super.method()` to the parent class (#1929). Callback-reference-call + // extraction on the arguments is skipped for the same reason as + // `this(args)` above. + if fn_node.kind() == "super" { + if let Some(call_info) = extract_call_info(&fn_node, node, source) { + symbols.calls.push(call_info); + } return; } // RES-2: {a:fnA,b:fnB}[k]() — inline object literal dispatch table. @@ -1570,7 +1788,7 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(cb_def) = extract_callback_definition(node, source) { symbols.definitions.push(cb_def); } - extract_callback_reference_calls(node, source, &mut symbols.calls); + extract_callback_reference_calls(node, source, callback_param_shapes, &mut symbols.calls); return; } } @@ -1580,7 +1798,7 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(cb_def) = extract_callback_definition(node, source) { symbols.definitions.push(cb_def); } - extract_callback_reference_calls(node, source, &mut symbols.calls); + extract_callback_reference_calls(node, source, callback_param_shapes, &mut symbols.calls); } fn handle_new_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { @@ -1656,9 +1874,13 @@ fn handle_dynamic_import(node: &Node, _fn_node: &Node, source: &[u8], symbols: & if let Some(str_node) = str_node { let mod_path = node_text(&str_node, source) .replace(&['\'', '"', '`'][..], ""); - let names = extract_dynamic_import_names(node, source); + let mut renamed_imports = Vec::new(); + let names = extract_dynamic_import_names(node, source, &mut renamed_imports); let mut imp = Import::new(mod_path, names, start_line(node)); imp.dynamic_import = Some(true); + if !renamed_imports.is_empty() { + imp.renamed_imports = Some(renamed_imports); + } symbols.imports.push(imp); } } @@ -1672,11 +1894,20 @@ fn handle_import_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(source_node) = source_node { let mod_path = node_text(&source_node, source) .replace(&['\'', '"'][..], ""); - let names = extract_import_names(node, source); + let mut renamed_imports = Vec::new(); + let mut type_only_names = Vec::new(); + let names = + extract_import_names_with_renames(node, source, &mut renamed_imports, &mut type_only_names); let mut imp = Import::new(mod_path, names, start_line(node)); if is_type_only { imp.type_only = Some(true); } + if !renamed_imports.is_empty() { + imp.renamed_imports = Some(renamed_imports); + } + if !type_only_names.is_empty() { + imp.type_only_names = Some(type_only_names); + } symbols.imports.push(imp); } } @@ -1700,6 +1931,10 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: & "class_declaration" | "abstract_class_declaration" => ("class", "name"), "interface_declaration" => ("interface", "name"), "type_alias_declaration" => ("type", "name"), + "lexical_declaration" | "variable_declaration" => { + collect_exported_var_declarations(node, decl, source, symbols); + return; + } _ => return, }; if let Some(n) = decl.child_by_field_name(field) { @@ -1711,10 +1946,62 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: & } } +/// Push `ExportInfo` entries for `export const/let/var …`, one per declarator. +/// +/// Named function/class/interface/type declarations carry their own `name` +/// field (handled above); a lexical/variable declaration doesn't, so each +/// declarator's value is classified the same way `handle_var_decl` classifies +/// it when creating the matching `Definition`: function-valued declarators +/// become kind "function"; any other `const` declarator becomes kind "constant", +/// regardless of initializer complexity (#1819). +/// Mirrors the WASM/TS extractor's `collectExportedDeclarations`. +/// +/// This predicate must stay identical to `handle_var_decl`'s: `insert_nodes.rs` +/// marks `exported = 1` by matching (name, kind, file, line) against +/// already-inserted definition rows, so a mismatched kind here silently +/// no-ops the UPDATE instead of marking the symbol exported (#1728). +fn collect_exported_var_declarations( + node: &Node, + decl: &Node, + source: &[u8], + symbols: &mut FileSymbols, +) { + let is_const = decl + .child(0) + .map(|c| node_text(&c, source) == "const") + .unwrap_or(false); + let line = start_line(node); + for i in 0..decl.child_count() { + let Some(declarator) = decl.child(i) else { continue }; + if declarator.kind() != "variable_declarator" { continue; } + let name_n = declarator.child_by_field_name("name"); + let value_n = declarator.child_by_field_name("value"); + let (Some(name_n), Some(value_n)) = (name_n, value_n) else { continue }; + if name_n.kind() != "identifier" { continue; } + let vt = value_n.kind(); + if vt == "arrow_function" || vt == "function_expression" || vt == "function" || vt == "generator_function" { + symbols.exports.push(ExportInfo { + name: node_text(&name_n, source).to_string(), + kind: "function".to_string(), + line, + }); + } else if is_const { + symbols.exports.push(ExportInfo { + name: node_text(&name_n, source).to_string(), + kind: "constant".to_string(), + line, + }); + } + } +} + fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut FileSymbols) { let mod_path = node_text(source_node, source) .replace(&['\'', '"'][..], ""); - let reexport_names = extract_import_names(node, source); + let mut reexport_renames = Vec::new(); + let mut type_only_names = Vec::new(); + let reexport_names = + extract_import_names_with_renames(node, source, &mut reexport_renames, &mut type_only_names); let text = node_text(node, source); let is_wildcard = text.contains("export *") || text.contains("export*"); let mut imp = Import::new(mod_path, reexport_names.clone(), start_line(node)); @@ -1722,6 +2009,9 @@ fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut if is_wildcard && reexport_names.is_empty() { imp.wildcard_reexport = Some(true); } + if !reexport_renames.is_empty() { + imp.renamed_imports = Some(reexport_renames); + } symbols.imports.push(imp); } @@ -1843,7 +2133,14 @@ fn walk_ast_nodes_depth(node: &Node, source: &[u8], ast_nodes: &mut Vec } return; } - "string" | "template_string" => { + // Guard on `is_named()`: tree-sitter-typescript's `predefined_type` + // production (the `string`/`number`/`boolean`/... primitive type + // keywords) lexes its keyword as an anonymous token whose `kind()` + // string is identical to the *named* `string` literal node type. + // Without this guard, `name: string` type annotations are + // misclassified as string-literal ast_nodes (#1729). Mirrors the + // WASM-side guard in `ast-store-visitor.ts::resolveAstKind`. + "string" | "template_string" if node.is_named() => { let raw = node_text(node, source); // Strip quotes to get content let content = raw @@ -2100,14 +2397,6 @@ fn extract_ts_enum_members(node: &Node, source: &[u8]) -> Vec { members } -fn is_js_literal(node: &Node) -> bool { - matches!(node.kind(), - "number" | "string" | "true" | "false" | "null" | "undefined" - | "template_string" | "regex" | "array" | "object" - | "unary_expression" | "binary_expression" | "new_expression" - ) -} - // ── Existing helpers ──────────────────────────────────────────────────────── fn extract_interface_methods( @@ -2120,15 +2409,21 @@ fn extract_interface_methods( if let Some(child) = body.child(i) { if child.kind() == "method_signature" || child.kind() == "property_signature" { if let Some(name_node) = child.child_by_field_name("name") { + let kind = if child.kind() == "method_signature" { + "method" + } else { + "property" + }; definitions.push(Definition { name: format!("{}.{}", iface_name, node_text(&name_node, source)), - kind: "method".to_string(), + kind: kind.to_string(), line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, complexity: None, cfg: None, children: None, + bodyless: Some(child.child_by_field_name("body").is_none()), }); } } @@ -2181,12 +2476,19 @@ fn extract_implements_depth(node: &Node, source: &[u8], result: &mut Vec } } -/// Callee names that idiomatically accept callback references. Member-expression -/// args (e.g. `auth.validate`) are only emitted as dynamic callback calls when -/// the callee is in this set; otherwise plain property reads passed as data -/// (`store.set(user.id, user)`) would emit spurious `id` calls with receiver -/// `user`. Identifier args are always emitted — collateral damage from dropping -/// them outweighs the FP risk for plain identifier data args. +/// Callee names that idiomatically accept callback references. Both identifier +/// (e.g. `handleToken`) and member-expression (e.g. `auth.validate`) args are +/// only emitted as dynamic callback calls when the callee is in this set; +/// otherwise plain values passed as data (`store.set(user.id, user)`, +/// `findMergeCandidates(communities)`) would emit spurious calls — e.g. `id` +/// with receiver `user`, or a fabricated edge to an unrelated same-named +/// function (issue #1741). +/// +/// Arbitrary user-defined higher-order functions (e.g. `processEach(users, +/// fn: UserProcessor)`) are neither name-allowlisted nor position-mapped (see +/// `positional_callback_arg_index`) — those are instead recognized via +/// `CallbackParamShapes`, which looks at the callee's own parameter type +/// (issue #1845), same-file only. /// /// Mirrors `CALLBACK_ACCEPTING_CALLEES` in `src/extractors/javascript.ts`. const CALLBACK_ACCEPTING_CALLEES: &[&str] = &[ @@ -2221,6 +2523,34 @@ const HTTP_VERB_CALLEES: &[&str] = &[ "get", "post", "put", "delete", "patch", "options", "head", "all", ]; +/// Callees whose callback argument sits at one specific positional index +/// rather than "any position" (the assumption behind `CALLBACK_ACCEPTING_CALLEES`, +/// needed for variadic Express/Router middleware chains like +/// `app.get(path, mw1, mw2, handler)`). +/// +/// `Array.from(arrayLike, mapFn, thisArg)` (also every TypedArray constructor, +/// e.g. `Uint8Array.from`) is the motivating case: `arrayLike` (index 0) is +/// plain data — treating it as a callback candidate would reintroduce the +/// exact name-collision false-positive class issue #1741 fixes — while +/// `mapFn` (index 1) is a genuine callback reference that should still +/// resolve. A callee listed here is implicitly callback-accepting (no +/// separate `CALLBACK_ACCEPTING_CALLEES` entry needed); only the arg at its +/// listed index is eligible. +/// +/// Name-based, not receiver-typed, so it can't distinguish `Array.from(x, +/// mapFn)` from an unrelated `.from(x, y)` shaped differently (e.g. +/// `Buffer.from(data, encoding)`) — that residual risk is far narrower than +/// the unconditional-emission bug this gate fixes, so it's accepted rather +/// than adding receiver-type tracking. +/// +/// Mirrors `POSITIONAL_CALLBACK_ARG_INDEX` in `src/extractors/javascript.ts`. +fn positional_callback_arg_index(callee_name: &str) -> Option { + match callee_name { + "from" => Some(1), + _ => None, + } +} + /// Extract the callee's final name (function identifier or member expression /// property) for callback-eligibility filtering. Returns `None` if the callee /// shape is not analyzable (e.g. computed subscripts, IIFEs). @@ -2248,37 +2578,290 @@ fn first_arg_is_string_literal(args_node: &Node) -> bool { false } -fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut Vec) { - let args = call_node.child_by_field_name("arguments") - .or_else(|| find_child(call_node, "arguments")); - let Some(args) = args else { return }; - let call_line = start_line(call_node); +/// Maps a function/method's bare name (matching what `extract_callee_name` +/// returns) to the set of its own parameter positions whose declared +/// TypeScript type is function-shaped (an inline arrow-function type, +/// `Function`, or a `type X = (...) => ...` alias). Built once per file by +/// `collect_callback_param_shapes` and consulted by +/// `extract_callback_reference_calls` to recognize identifier arguments +/// passed to arbitrary user-defined higher-order functions (issue #1845), +/// not just the `CALLBACK_ACCEPTING_CALLEES` name allowlist. +/// +/// Name-keyed rather than receiver-typed, consistent with the rest of this +/// gate (see `positional_callback_arg_index`'s doc comment for the same +/// tradeoff) — a same-named function/method elsewhere in the file with an +/// unrelated non-function-shaped parameter at the same index is a residual, +/// accepted risk. +/// +/// Mirrors `CallbackParamShapes` in `src/extractors/javascript.ts`. +type CallbackParamShapes = HashMap>; + +/// True iff `type_node` denotes a function-shaped TypeScript type: an inline +/// arrow-function type (`(x: T) => R`), the `Function` type, a parenthesized +/// function type, a generic instantiation of one (`UserProcessor`), or a +/// `type` alias name that itself resolves to one of the above (see +/// `collect_function_shaped_type_aliases`). +/// +/// Deliberately not full type-checking: union/intersection types and +/// interface call signatures are not recognized, matching the same +/// "defensible heuristic, not full inference" scope as `extract_simple_type_name`. +/// +/// Mirrors `isFunctionShapedTypeNode` in `src/extractors/javascript.ts`. +fn is_function_shaped_type_node( + type_node: &Node, + source: &[u8], + alias_shapes: &HashMap, +) -> bool { + match type_node.kind() { + "function_type" => true, + "parenthesized_type" => type_node + .named_child(0) + .map(|inner| is_function_shaped_type_node(&inner, source, alias_shapes)) + .unwrap_or(false), + "type_identifier" => { + let name = node_text(type_node, source); + name == "Function" || alias_shapes.get(name).copied().unwrap_or(false) + } + "generic_type" => type_node + .child(0) + .map(|base| is_function_shaped_type_node(&base, source, alias_shapes)) + .unwrap_or(false), + _ => false, + } +} - let callee_name = extract_callee_name(call_node, source); - // .call() / .apply() / .bind() — the first arg is the `this` context (not a - // callback of the enclosing function) and subsequent args flow into the - // delegated function's parameters. Emitting them here would produce - // false-positive edges from the *calling* function. This-rebinding - // (fn::this → ctx) is handled separately by collect_this_call_and_bindings. - if matches!(callee_name, Some("call") | Some("apply") | Some("bind")) { - return; +/// True iff a `type_annotation` node's inner type is function-shaped. +/// +/// Mirrors `isFunctionShapedTypeAnnotation` in `src/extractors/javascript.ts`. +fn is_function_shaped_type_annotation( + type_annotation_node: &Node, + source: &[u8], + alias_shapes: &HashMap, +) -> bool { + for i in 0..type_annotation_node.child_count() { + if let Some(child) = type_annotation_node.child(i) { + if child.kind() != ":" { + return is_function_shaped_type_node(&child, source, alias_shapes); + } + } } - let mut member_expr_args_allowed = callee_name - .map(|n| CALLBACK_ACCEPTING_CALLEES.contains(&n)) - .unwrap_or(false); - if member_expr_args_allowed { - if let Some(name) = callee_name { - if HTTP_VERB_CALLEES.contains(&name) { - // HTTP verbs require a string-literal route path to be treated as a - // callback-accepting API; otherwise `cache.get(user.id)` etc. would - // still emit `id` as a dynamic call. - member_expr_args_allowed = first_arg_is_string_literal(&args); + false +} + +/// Walk the file for `type X = ...` aliases and classify each by whether it +/// resolves to a function-shaped type, following one level of alias-to-alias +/// indirection (`type A = B` where `B` is itself function-shaped) with a +/// cycle guard. Motivating case: `export type UserProcessor = (user: User) => void;`. +/// +/// Mirrors `collectFunctionShapedTypeAliases` in `src/extractors/javascript.ts`. +fn collect_function_shaped_type_aliases(root: &Node, source: &[u8]) -> HashMap { + let mut direct_alias_of: HashMap = HashMap::new(); + let mut resolved: HashMap = HashMap::new(); + + fn walk( + node: &Node, + source: &[u8], + depth: usize, + direct_alias_of: &mut HashMap, + resolved: &mut HashMap, + ) { + if depth >= MAX_WALK_DEPTH { + return; + } + if node.kind() == "type_alias_declaration" { + let name_node = node.child_by_field_name("name"); + let value_node = node.child_by_field_name("value"); + if let (Some(name_node), Some(value_node)) = (name_node, value_node) { + let name = node_text(&name_node, source).to_string(); + if value_node.kind() == "type_identifier" { + direct_alias_of.insert(name, node_text(&value_node, source).to_string()); + } else { + let shaped = is_function_shaped_type_node(&value_node, source, resolved); + resolved.insert(name, shaped); + } + } + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, source, depth + 1, direct_alias_of, resolved); } } } + walk(root, source, 0, &mut direct_alias_of, &mut resolved); + + // Resolve `type A = B` chains against the direct classifications above. + for (name, alias_of) in &direct_alias_of { + if !resolved.contains_key(name) { + let shaped = + alias_of == "Function" || resolved.get(alias_of).copied().unwrap_or(false); + resolved.insert(name.clone(), shaped); + } + } + resolved +} + +/// Walk the whole file once to record, per `CallbackParamShapes`, which +/// parameter positions of every `function`/method declaration are +/// function-shaped — the callee-definition side of recognizing identifier +/// arguments to arbitrary user-defined higher-order functions (issue #1845). +/// +/// Same-file only: a call site whose callee is defined in another file has no +/// entry here and falls back to the existing name/position allowlist. +/// +/// Mirrors `collectCallbackParamShapes` in `src/extractors/javascript.ts`. +fn collect_callback_param_shapes(root: &Node, source: &[u8]) -> CallbackParamShapes { + let alias_shapes = collect_function_shaped_type_aliases(root, source); + let mut shapes: CallbackParamShapes = HashMap::new(); + + fn record_function_params( + name_node: Option, + fn_node: &Node, + source: &[u8], + alias_shapes: &HashMap, + shapes: &mut CallbackParamShapes, + ) { + let Some(name_node) = name_node else { return }; + let params_node = fn_node + .child_by_field_name("parameters") + .or_else(|| find_child(fn_node, "formal_parameters")); + let Some(params_node) = params_node else { return }; + + let mut arg_index: usize = 0; + for child in iter_children(¶ms_node, PUNCTUATION_TOKENS) { + let kind = child.kind(); + if kind == "required_parameter" || kind == "optional_parameter" { + // TypeScript's explicit `this` parameter (`function f(this: Foo, cb: Bar)`) + // is compiled away and never appears at the call site, so it must not + // consume an argument-index slot — otherwise every later parameter's + // index would be off by one relative to the call's actual arguments. + let is_this_param = child + .child_by_field_name("pattern") + .or_else(|| child.child_by_field_name("name")) + .map(|n| n.kind() == "this") + .unwrap_or(false); + if is_this_param { + continue; + } + } + if kind == "required_parameter" || kind == "optional_parameter" { + if let Some(type_anno) = find_child(&child, "type_annotation") { + if is_function_shaped_type_annotation(&type_anno, source, alias_shapes) { + shapes + .entry(node_text(&name_node, source).to_string()) + .or_default() + .insert(arg_index); + } + } + } + arg_index += 1; + } + } + + fn walk( + node: &Node, + source: &[u8], + depth: usize, + alias_shapes: &HashMap, + shapes: &mut CallbackParamShapes, + ) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "function_declaration" | "generator_function_declaration" => { + record_function_params(node.child_by_field_name("name"), node, source, alias_shapes, shapes); + } + "method_definition" => { + record_function_params(node.child_by_field_name("name"), node, source, alias_shapes, shapes); + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + walk(&child, source, depth + 1, alias_shapes, shapes); + } + } + } + walk(root, source, 0, &alias_shapes, &mut shapes); + + shapes +} + +/// Extract Call entries for named function references passed as arguments. +/// e.g. `router.use(handleToken, checkAuth)` yields calls to handleToken and checkAuth. +/// `app.use(auth.validate)` yields a call to validate with receiver auth. +/// +/// Both identifier and member-expression args are only emitted when the +/// callee is in `CALLBACK_ACCEPTING_CALLEES`, the argument sits at the +/// specific index a `positional_callback_arg_index` entry designates, or the +/// callee is a same-file function/method whose own parameter at that index +/// is function-shaped per `CallbackParamShapes` (issue #1845 — arbitrary +/// user-defined higher-order functions like `processEach(users, fn: +/// UserProcessor)`, which no name/position allowlist can enumerate). +/// +/// Known gap: `CallbackParamShapes` only covers callees defined in the same +/// file. A cross-file arbitrary higher-order function still falls back to +/// the name/position allowlist. Extending this to cross-file callees needs +/// the resolver's import-resolution machinery; tracked as a follow-up. +/// +/// Mirrors `extractCallbackReferenceCalls` in `src/extractors/javascript.ts`. +fn extract_callback_reference_calls( + call_node: &Node, + source: &[u8], + callback_param_shapes: &CallbackParamShapes, + calls: &mut Vec, +) { + let args = call_node.child_by_field_name("arguments") + .or_else(|| find_child(call_node, "arguments")); + let Some(args) = args else { return }; + let call_line = start_line(call_node); + + let callee_name = extract_callee_name(call_node, source); + // .call() / .apply() / .bind() — the first arg is the `this` context (not a + // callback of the enclosing function) and subsequent args flow into the + // delegated function's parameters. Emitting them here would produce + // false-positive edges from the *calling* function. This-rebinding + // (fn::this → ctx) is handled separately by collect_this_call_and_bindings. + if matches!(callee_name, Some("call") | Some("apply") | Some("bind")) { + return; + } + let mut callback_args_allowed = callee_name + .map(|n| CALLBACK_ACCEPTING_CALLEES.contains(&n)) + .unwrap_or(false); + if callback_args_allowed { + if let Some(name) = callee_name { + if HTTP_VERB_CALLEES.contains(&name) { + // HTTP verbs require a string-literal route path to be treated as a + // callback-accepting API; otherwise `cache.get(user.id)` etc. would + // still emit `id` as a dynamic call. + callback_args_allowed = first_arg_is_string_literal(&args); + } + } + } + + let positional_index = callee_name.and_then(positional_callback_arg_index); + let callee_param_shapes = callee_name.and_then(|n| callback_param_shapes.get(n)); + if !callback_args_allowed + && positional_index.is_none() + && callee_param_shapes.map(|s| s.is_empty()).unwrap_or(true) + { + return; + } + + for (arg_index, child) in iter_children(&args, PUNCTUATION_TOKENS).enumerate() { + if let Some(idx) = positional_index { + // A positional entry restricts eligibility to its one designated + // index, regardless of what the generic (any-position) gate above + // decided. + if arg_index != idx { + continue; + } + } else if !callback_args_allowed + && !callee_param_shapes.map(|s| s.contains(&arg_index)).unwrap_or(false) + { + continue; + } - for i in 0..args.child_count() { - let Some(child) = args.child(i) else { continue }; match child.kind() { "identifier" => { calls.push(Call { @@ -2289,7 +2872,7 @@ fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut ..Default::default() }); } - "member_expression" if member_expr_args_allowed => { + "member_expression" => { if let Some(prop) = child.child_by_field_name("property") { let receiver = child.child_by_field_name("object") .map(|obj| extract_receiver_name(&obj, source)); @@ -2307,6 +2890,128 @@ fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut } } +/// Collect a dynamic value-ref `Call` for an object-literal `pair` node whose +/// value is a bare identifier — e.g. `{ resolve: someFunction }`, the +/// "dispatch table" pattern (`{ matches, resolve }`-style handler arrays, +/// issue #1771). Restricted to plain `identifier` values: call expressions, +/// member expressions, and inline function/arrow values are handled by their +/// own extraction paths (regular call resolution, `seed_objlit_type_map_entries` +/// / `extract_object_literal_functions`) and must not be double-counted here. +/// +/// Emitted unconditionally for every bare-identifier property value in the +/// file — `dynamic_kind: "value-ref"` is resolved downstream (build_edges.rs) +/// against function/method-kind targets ONLY, so plain data references +/// (`{ name: SOME_CONSTANT }`) naturally fail to resolve into an edge rather +/// than needing a structural allowlist gate here. +/// +/// `key_expr` carries the property KEY (e.g. `resolve`), distinct from `name` +/// (the referenced value's own identifier, e.g. `someFunction`) — the +/// downstream "is this property ever invoked" liveness check (#1895) needs +/// the key, since that's the name a dispatch consumer would actually call +/// (`table.resolve(...)`), not the function's own declared name. +/// +/// Mirrors `collectObjectLiteralValueRefCall` in `src/extractors/javascript.ts`. +fn handle_object_literal_pair_value_ref(node: &Node, source: &[u8], calls: &mut Vec) { + let Some(value_n) = node.child_by_field_name("value") else { return }; + if value_n.kind() != "identifier" { + return; + } + let text = node_text(&value_n, source); + if JS_BUILTIN_GLOBALS.contains(&text) { + return; + } + let key_expr = node.child_by_field_name("key").and_then(|k| resolve_pair_key_name(&k, source)); + calls.push(Call { + name: text.to_string(), + line: start_line(&value_n), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + key_expr, + ..Default::default() + }); +} + +/// Collect a dynamic value-ref `Call` for an object-literal shorthand property +/// (`{ someFunction }`) — semantically identical to `{ someFunction: someFunction }`. +/// `shorthand_property_identifier` only appears inside object-literal +/// EXPRESSIONS in this grammar (destructuring patterns use the distinct +/// `shorthand_property_identifier_pattern` kind), so this can't misfire on +/// destructuring targets. +/// +/// `key_expr` equals `name` here — the property key and the referenced value +/// are the same identifier in shorthand form (#1895). +/// +/// Mirrors the walk path's `shorthand_property_identifier` handling in +/// `src/extractors/javascript.ts`'s `runCollectorWalk` (issue #1771). +fn handle_object_literal_shorthand_value_ref(node: &Node, source: &[u8], calls: &mut Vec) { + let text = node_text(node, source); + if JS_BUILTIN_GLOBALS.contains(&text) { + return; + } + calls.push(Call { + name: text.to_string(), + line: start_line(node), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + key_expr: Some(text.to_string()), + ..Default::default() + }); +} + +/// Collect a dynamic value-ref `Call` for the right-hand operand of an +/// `instanceof` binary expression when it's a bare identifier — e.g. +/// `err instanceof CodegraphError` (issue #1784). `instanceof` reads its +/// right operand as a value (a prototype-chain check), never calls it, so +/// this is the same "referenced as a value, not a call site" shape as the +/// object-literal (#1771) and Lua builtin-reassignment (#1776) sites — +/// reused rather than given its own `dynamic_kind` (see ADR-002). +/// +/// Restricted to plain `identifier` right operands: `a instanceof B.C` +/// (`member_expression`) and `a instanceof (foo())` (parenthesized/call +/// expressions) are left unresolved rather than guessing — same +/// "restrict to the simplest syntactic shape" precedent as #1771. +/// +/// Unlike the function/method-only value-ref sites, `instanceof`'s operand +/// is always a class/constructor — the resolver-side kind filter in +/// `build_edges.rs` accepts `class`-kind targets in addition to +/// function/method for this reason. +/// +/// Mirrors `collectInstanceofValueRefCall` in `src/extractors/javascript.ts`. +fn handle_instanceof_value_ref(node: &Node, source: &[u8], calls: &mut Vec) { + let Some(operator_n) = node.child_by_field_name("operator") else { return }; + if node_text(&operator_n, source) != "instanceof" { + return; + } + let Some(right_n) = node.child_by_field_name("right") else { return }; + if right_n.kind() != "identifier" { + return; + } + let text = node_text(&right_n, source); + if JS_BUILTIN_GLOBALS.contains(&text) { + return; + } + calls.push(Call { + name: text.to_string(), + line: start_line(&right_n), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); +} + +/// Extract definitions from destructured object bindings: `const { handleToken, +/// checkPermissions } = initAuth(...)` creates definitions for `handleToken` +/// and `checkPermissions`, kind `constant` — matching the convention for plain +/// `const x = ` bindings and array-pattern destructuring. +/// +/// Every call site of this function is already gated to `const` declarations +/// (never `let`/`var`), so `constant` is unconditionally correct here. Prior to +/// #1773 this used `kind: "function"` on the theory that destructured names +/// are usually callbacks, but that miscategorized every non-function +/// destructured value (e.g. `const { dbPath } = workerData`). `constant`-kind +/// nodes remain fully resolvable as call targets — call-target resolution is +/// kind-agnostic — so callback-style destructured bindings still resolve. +/// Mirrors the TS extractor's `extractDestructuredBindings`. fn extract_destructured_bindings( pattern: &Node, source: &[u8], @@ -2320,13 +3025,14 @@ fn extract_destructured_bindings( "shorthand_property_identifier_pattern" | "shorthand_property_identifier" => { definitions.push(Definition { name: node_text(&child, source).to_string(), - kind: "function".to_string(), + kind: "constant".to_string(), line, end_line: Some(end_line), decorators: None, complexity: None, cfg: None, children: None, + bodyless: None, }); } "pair_pattern" | "pair" => { @@ -2336,17 +3042,95 @@ fn extract_destructured_bindings( { definitions.push(Definition { name: node_text(&value, source).to_string(), - kind: "function".to_string(), + kind: "constant".to_string(), + line, + end_line: Some(end_line), + decorators: None, + complexity: None, + cfg: None, + children: None, + bodyless: None, + }); + } + } + } + _ => {} + } + } +} + +/// Extract a per-element "constant" Definition from each bound identifier in +/// an array-destructuring pattern (`const [a, b] = fn()`) — the array-pattern +/// counterpart to `extract_destructured_bindings`'s per-property handling of +/// object patterns (#1773). Each bound name becomes its own resolvable node, +/// superseding the prior single-node-named-by-raw-pattern-text approach +/// (`[a, b]` as one unresolvable node), which was never a real identifier and +/// could never be a call target (#1901). Mirrors the TS extractor's +/// `extractArrayPatternBindings`. +fn extract_array_pattern_bindings( + pattern: &Node, + source: &[u8], + line: u32, + end_line: u32, + definitions: &mut Vec, +) { + for i in 0..pattern.child_count() { + let Some(child) = pattern.child(i) else { continue }; + match child.kind() { + "identifier" => { + definitions.push(Definition { + name: node_text(&child, source).to_string(), + kind: "constant".to_string(), + line, + end_line: Some(end_line), + decorators: None, + complexity: None, + cfg: None, + children: None, + bodyless: None, + }); + } + "assignment_pattern" => { + if let Some(left) = child.child_by_field_name("left") { + if left.kind() == "identifier" { + definitions.push(Definition { + name: node_text(&left, source).to_string(), + kind: "constant".to_string(), line, end_line: Some(end_line), decorators: None, complexity: None, cfg: None, children: None, + bodyless: None, }); } } } + "rest_pattern" | "rest_element" => { + // The identifier is at child index 1 (index 0 is the `...` + // token itself) — mirroring extract_js_parameters' own + // rest_pattern handling, which scans all children for the + // identifier rather than assuming a fixed index. + for j in 0..child.child_count() { + if let Some(inner) = child.child(j) { + if inner.kind() == "identifier" { + definitions.push(Definition { + name: node_text(&inner, source).to_string(), + kind: "constant".to_string(), + line, + end_line: Some(end_line), + decorators: None, + complexity: None, + cfg: None, + children: None, + bodyless: None, + }); + break; + } + } + } + } _ => {} } } @@ -2381,7 +3165,9 @@ fn extract_receiver_name(obj: &Node, source: &[u8]) -> String { } /// Return the first non-punctuation argument node from a call_expression. -fn get_first_call_arg<'a>(call_node: &'a Node, source: &[u8]) -> Option> { +/// Mirrors `getFirstCallArg` in src/extractors/javascript.ts, which likewise +/// only needs node structure (not source text) to locate the argument. +fn get_first_call_arg<'a>(call_node: &'a Node) -> Option> { let args = call_node.child_by_field_name("arguments") .or_else(|| find_child(call_node, "arguments"))?; for i in 0..args.child_count() { @@ -2437,7 +3223,7 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< let name = node_text(fn_node, source); if name == "eval" { // eval(code) — dynamic code execution; capture first arg if string literal - let key_expr = get_first_call_arg(call_node, source) + let key_expr = get_first_call_arg(call_node) .filter(|a| a.kind() == "string" || a.kind() == "template_string") .map(|a| node_text(&a, source).to_string()); return Some(Call { @@ -2471,7 +3257,7 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< // Note: Reflect.call does not exist in the ECMAScript spec; only Reflect.apply, construct, get, etc. if is_reflect && prop_text == "apply" { return Some(extract_reflect_callee_from_arg( - get_first_call_arg(call_node, source), + get_first_call_arg(call_node), call_line, source, )); @@ -2480,7 +3266,7 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< // Reflect.construct(Target, args) — extract constructor as callee if is_reflect && prop_text == "construct" { return Some(extract_reflect_callee_from_arg( - get_first_call_arg(call_node, source), + get_first_call_arg(call_node), call_line, source, )); @@ -2638,6 +3424,14 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< } None } + // Bare `super(...)` — see the early dispatch in `handle_call_expr` for why + // callback-reference-call extraction is skipped for the arguments here. + "super" => Some(Call { + name: "constructor".to_string(), + line: start_line(call_node), + receiver: Some("super".to_string()), + ..Default::default() + }), _ => None, } } @@ -2730,6 +3524,7 @@ fn extract_callback_definition(call_node: &Node, source: &[u8]) -> Option Option Option Option Vec { - // Walk up: call_expression → await_expression? → variable_declarator +/// Handles: `const { a, b } = await import(...)`, `const mod = await import(...)`, +/// casts/parens wrapping the awaited call, e.g. +/// `const { a } = (await import(...)) as { a: Fn }`, and destructuring +/// renames, e.g. `const { a: b } = await import(...)`. +/// +/// `renamed_out` is populated with `{ local, imported }` pairs for every +/// `{ imported: local }` specifier — mirrors `extract_import_names_with_renames`'s +/// static-import convention (#1730) so call-edge resolution can recover the +/// original exported name when a call site uses the local alias (#1824). +fn extract_dynamic_import_names( + call_node: &Node, + source: &[u8], + renamed_out: &mut Vec, +) -> Vec { + // Walk up through any combination/nesting of await/parenthesized/as-cast + // wrappers to reach the variable_declarator. let mut current = call_node.parent(); - if let Some(parent) = current { - if parent.kind() == "await_expression" { + while let Some(parent) = current { + if DYNAMIC_IMPORT_WRAPPER_KINDS.contains(&parent.kind()) { current = parent.parent(); + } else { + break; } } let declarator = match current { @@ -2841,7 +3663,7 @@ fn extract_dynamic_import_names(call_node: &Node, source: &[u8]) -> Vec return Vec::new(); }; match name_node.kind() { - "object_pattern" => collect_object_pattern_names(&name_node, source), + "object_pattern" => collect_object_pattern_names(&name_node, source, renamed_out), "identifier" => vec![node_text(&name_node, source).to_string()], "array_pattern" => collect_array_pattern_names(&name_node, source), _ => Vec::new(), @@ -2849,7 +3671,11 @@ fn extract_dynamic_import_names(call_node: &Node, source: &[u8]) -> Vec } /// Collect names from `const { a, b } = await import(...)` -fn collect_object_pattern_names(pattern: &Node, source: &[u8]) -> Vec { +fn collect_object_pattern_names( + pattern: &Node, + source: &[u8], + renamed_out: &mut Vec, +) -> Vec { let mut names = Vec::new(); for i in 0..pattern.child_count() { let Some(child) = pattern.child(i) else { continue }; @@ -2858,9 +3684,46 @@ fn collect_object_pattern_names(pattern: &Node, source: &[u8]) -> Vec { names.push(node_text(&child, source).to_string()); } "pair_pattern" | "pair" => { - // { exportName: localAlias } → extract the key (export name) - if let Some(key) = child.child_by_field_name("key") { - names.push(node_text(&key, source).to_string()); + // { imported: local } → the local binding (`value`) is what + // call sites actually reference; `key` is the name exported + // by the target module. Preferring `key` unconditionally (as + // this branch used to) silently dropped the local alias for + // every renamed destructure — the same class of bug fixed for + // static `import { X as Y }` specifiers in #1730 (#1824). + let key = child.child_by_field_name("key"); + let value = child.child_by_field_name("value"); + let local_node = match value.map(|v| (v.kind(), v)) { + Some(("identifier", v)) | Some(("shorthand_property_identifier_pattern", v)) => { + Some(v) + } + Some(("assignment_pattern", v)) => { + // { imported: local = defaultValue } — the local + // binding is the assignment_pattern's left identifier. + v.child_by_field_name("left") + .filter(|left| left.kind() == "identifier") + } + _ => None, + }; + match (local_node, key) { + (Some(local_node), Some(key)) => { + let local_text = node_text(&local_node, source).to_string(); + let key_text = node_text(&key, source).to_string(); + if local_text != key_text { + renamed_out.push(RenamedImport { + local: local_text.clone(), + imported: key_text, + }); + } + names.push(local_text); + } + (None, Some(key)) => { + // Nested pattern (`{ foo: { nested } }`) or other + // unsupported value shape — no single local binding + // to extract; fall back to the key so the specifier + // isn't dropped entirely. + names.push(node_text(&key, source).to_string()); + } + _ => {} } } "object_assignment_pattern" => { @@ -2901,36 +3764,129 @@ fn collect_array_pattern_names(pattern: &Node, source: &[u8]) -> Vec { names } -/// Extract the identifier from a rest/spread element (`...rest` → `rest`) +/// Extract the identifier from a rest/spread element (`...rest` → `rest`). +/// Scans all children for the `identifier` node rather than assuming a fixed +/// index — the `...` token itself is child 0, so indexing into a fixed slot +/// silently returns the wrong node and drops the binding entirely (#1920). +/// Mirrors `extract_array_pattern_bindings`'s own rest_pattern handling. fn extract_rest_identifier(rest_node: &Node, source: &[u8], names: &mut Vec) { - if let Some(inner) = rest_node.child(0) { - if inner.kind() == "identifier" { - names.push(node_text(&inner, source).to_string()); + for i in 0..rest_node.child_count() { + if let Some(inner) = rest_node.child(i) { + if inner.kind() == "identifier" { + names.push(node_text(&inner, source).to_string()); + break; + } } } } -fn extract_import_names(node: &Node, source: &[u8]) -> Vec { +/// Extract import names and collect `{ local, imported }` pairs for +/// `import_specifier` nodes that rename a binding (`import { X as Y }`), plus +/// the local names of specifiers carrying an inline `type`/`typeof` +/// modifier (`import { type X }`, #1813). Mirrors `extractImportNames`'s +/// `renamedOut`/`typeOnlyOut` parameters in src/extractors/javascript.ts +/// (#1730, #1813). +fn extract_import_names_with_renames( + node: &Node, + source: &[u8], + renamed_out: &mut Vec, + type_only_out: &mut Vec, +) -> Vec { let mut names = Vec::new(); - scan_import_names(node, source, &mut names); + scan_import_names(node, source, &mut names, renamed_out, type_only_out); names } -fn scan_import_names(node: &Node, source: &[u8], names: &mut Vec) { - scan_import_names_depth(node, source, names, 0); +fn scan_import_names( + node: &Node, + source: &[u8], + names: &mut Vec, + renamed_out: &mut Vec, + type_only_out: &mut Vec, +) { + scan_import_names_depth(node, source, names, renamed_out, type_only_out, 0); } -fn scan_import_names_depth(node: &Node, source: &[u8], names: &mut Vec, depth: usize) { +/// Grammar note (see tree-sitter-javascript): for `import_specifier`, the +/// `name` field is *always* present — it holds the name as declared by the +/// source module. `alias` is only present for `X as Y` and holds the *local* +/// binding actually referenced by call sites in this file. Preferring `name` +/// unconditionally (as this function used to) silently drops the local alias +/// for every renamed import: call sites use `Y`, not `X` (#1730). +/// +/// `export_specifier` has the same `name`/`alias` shape but the opposite +/// consumer: `name` (X) is the declaration being re-exported, `alias` (Y) is +/// the external name a consumer of *this* barrel imports. `names` keeps +/// recording X (barrel/reexport tracing keys off the original declaration — +/// see `resolve_barrel_export`), but when the two differ, `renamed_out` also +/// receives the `{ local: Y, imported: X }` pair so barrel resolution can +/// translate a consumer's requested external name back to X (#1823). +/// +/// The tree-sitter-typescript grammar defines `import_specifier` as +/// `optional(choice('type', 'typeof'))` followed by the name/alias fields, so +/// an inline per-specifier type modifier (`import { type X }`) — when +/// present — is always the specifier's first child (#1813). +fn scan_import_names_depth( + node: &Node, + source: &[u8], + names: &mut Vec, + renamed_out: &mut Vec, + type_only_out: &mut Vec, + depth: usize, +) { if depth >= MAX_WALK_DEPTH { return; } match node.kind() { - "import_specifier" | "export_specifier" => { - let name_node = node - .child_by_field_name("name") - .or_else(|| node.child_by_field_name("alias")); + "import_specifier" => { + let source_name_node = node.child_by_field_name("name"); + let alias_node = node.child_by_field_name("alias"); + let local_node = alias_node.or(source_name_node); + if let Some(local_node) = local_node { + let local_text = node_text(&local_node, source).to_string(); + names.push(local_text.clone()); + if let (Some(alias), Some(source_name)) = (alias_node, source_name_node) { + let alias_text = node_text(&alias, source); + let source_text = node_text(&source_name, source); + if alias_text != source_text { + renamed_out.push(RenamedImport { + local: alias_text.to_string(), + imported: source_text.to_string(), + }); + } + } + if let Some(modifier) = node.child(0) { + if modifier.kind() == "type" || modifier.kind() == "typeof" { + type_only_out.push(local_text); + } + } + } else { + names.push(node_text(node, source).to_string()); + } + } + "export_specifier" => { + // export_specifier's `name` is the local declaration being (re-)exported; + // `alias` is the external name it's exposed as. Barrel/reexport tracing + // (resolve_barrel_export) keys off the *original* declaration name, so + // this branch keeps picking `name` first — do not unify with the + // import_specifier branch above. When `alias` differs from `name`, the + // rename pair is recorded in renamed_out so resolve_barrel_export can + // map a consumer's requested external name (Y) back to X (#1823). + let source_name_node = node.child_by_field_name("name"); + let alias_node = node.child_by_field_name("alias"); + let name_node = source_name_node.or(alias_node); if let Some(name_node) = name_node { names.push(node_text(&name_node, source).to_string()); + if let (Some(alias), Some(source_name)) = (alias_node, source_name_node) { + let alias_text = node_text(&alias, source); + let source_text = node_text(&source_name, source); + if alias_text != source_text { + renamed_out.push(RenamedImport { + local: alias_text.to_string(), + imported: source_text.to_string(), + }); + } + } } else { names.push(node_text(node, source).to_string()); } @@ -2949,7 +3905,7 @@ fn scan_import_names_depth(node: &Node, source: &[u8], names: &mut Vec, } for i in 0..node.child_count() { if let Some(child) = node.child(i) { - scan_import_names_depth(&child, source, names, depth + 1); + scan_import_names_depth(&child, source, names, renamed_out, type_only_out, depth + 1); } } } @@ -3447,24 +4403,21 @@ fn collect_object_rest_params(node: &Node, source: &[u8], symbols: &mut FileSymb } "pair" => { // object-literal method: `{ bar: function({ ...rest }) {} }`. - // Computed keys are skipped — they can never match a paramBinding callee. + // Computed keys resolve through resolve_pair_key_name, which unwraps resolvable + // string literals (e.g. `['bar']`) and returns None for non-string computed keys + // (e.g. `[Symbol.iterator]`) — those can never match a paramBinding callee. if let (Some(key_n), Some(value_n)) = ( node.child_by_field_name("key"), node.child_by_field_name("value"), ) { let vt = value_n.kind(); - if key_n.kind() != "computed_property_name" - && (vt == "arrow_function" || vt == "function_expression" || vt == "generator_function") - { - let key_text = node_text(&key_n, source); - fn_name = Some(if key_n.kind() == "string" { - key_text[1..key_text.len() - 1].to_string() - } else { - key_text.to_string() - }); - params_node = value_n - .child_by_field_name("parameters") - .or_else(|| find_child(&value_n, "formal_parameters")); + if vt == "arrow_function" || vt == "function_expression" || vt == "generator_function" { + if let Some(key_name) = resolve_pair_key_name(&key_n, source) { + fn_name = Some(key_name); + params_node = value_n + .child_by_field_name("parameters") + .or_else(|| find_child(&value_n, "formal_parameters")); + } } } } @@ -3589,6 +4542,42 @@ mod tests { assert!(names.contains(&"Foo")); assert!(names.contains(&"Foo.bar")); assert!(names.contains(&"Foo.baz")); + // Real class methods have a body — a dotted name alone must never be + // treated as a signature-only stub (#1922). + let bar = s.definitions.iter().find(|d| d.name == "Foo.bar").unwrap(); + assert_ne!(bar.bodyless, Some(true)); + } + + /// Regression test for #1922: an interface's `method_signature` structurally + /// has no body field and must be marked `bodyless`, even when the signature + /// spans multiple lines (the exact shape #606's original dot-check targeted). + /// A real, dotted class method implementing the same interface must NOT be + /// affected by the interface's own stub — it keeps a real body and complexity. + #[test] + fn interface_method_signature_is_bodyless_but_implementing_class_method_is_not() { + let s = parse_ts( + "interface Repo {\n\ + save(\n\ + id: string,\n\ + value: number,\n\ + ): boolean;\n\ + }\n\ + class InMemoryRepo implements Repo {\n\ + save(id: string, value: number): boolean {\n\ + if (value < 0) { return false; }\n\ + return true;\n\ + }\n\ + }\n", + ); + let iface_save = s.definitions.iter().find(|d| d.name == "Repo.save").unwrap(); + assert_eq!(iface_save.bodyless, Some(true)); + + let class_save = s + .definitions + .iter() + .find(|d| d.name == "InMemoryRepo.save") + .unwrap(); + assert_ne!(class_save.bodyless, Some(true)); } #[test] @@ -3599,6 +4588,30 @@ mod tests { assert_eq!(s.imports[0].names, vec!["readFile"]); } + /// Regression test for #1730: `import { X as Y }` must record the *local* + /// binding (Y) in `names` — that's what call sites reference — plus the + /// `{ local: Y, imported: X }` pair in `renamed_imports` so call-edge + /// resolution can recover the original exported name X. + #[test] + fn renamed_import_records_local_name_and_rename_pair() { + let s = parse_js("import { collectFiles as collectFilesUtil } from './helpers';"); + assert_eq!(s.imports.len(), 1); + assert_eq!(s.imports[0].names, vec!["collectFilesUtil"]); + let renamed = s.imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for a renamed specifier"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "collectFilesUtil"); + assert_eq!(renamed[0].imported, "collectFiles"); + } + + #[test] + fn non_renamed_import_has_no_renamed_imports() { + let s = parse_js("import { readFile } from 'fs';"); + assert!(s.imports[0].renamed_imports.is_none()); + } + #[test] fn finds_calls() { let s = parse_js("function f() { console.log('hi'); foo(); }"); @@ -3760,6 +4773,98 @@ mod tests { assert_eq!(s.definitions[0].name, "main"); } + // ── #1819: top-level const with a non-"literal-shaped" initializer ──────── + + #[test] + fn extracts_const_with_member_expression_initializer_as_constant() { + // Repro from #1819: a parenthesized member-expression initializer + // (`(...).version`) was not one of the recognized "literal" shapes, so + // the whole declaration was silently dropped — not just unexported, + // absent from `definitions` entirely. + let s = parse_js( + "const CODEGRAPH_VERSION = (JSON.parse(readFileSync(pkgPath, 'utf-8'))).version;", + ); + let def = s + .definitions + .iter() + .find(|d| d.name == "CODEGRAPH_VERSION") + .unwrap_or_else(|| panic!("CODEGRAPH_VERSION should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + + #[test] + fn extracts_const_with_call_expression_initializer_as_constant() { + let s = parse_js("const config = loadConfig();"); + let def = s + .definitions + .iter() + .find(|d| d.name == "config") + .unwrap_or_else(|| panic!("config should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + + #[test] + fn exports_const_with_call_expression_initializer() { + let s = parse_js("export const config = loadConfig();"); + assert!( + s.exports.iter().any(|e| e.name == "config" && e.kind == "constant"), + "config should be listed as an exported constant; got: {:?}", + s.exports + ); + } + + #[test] + fn extracts_const_array_pattern_with_call_expression_initializer() { + // Parity with the identifier case above: array-pattern names must also + // be discoverable regardless of initializer complexity — one + // definition per bound identifier (#1901), not a single node named + // by the raw pattern text. + let s = parse_js("const [a, b] = computePair();"); + for name in ["a", "b"] { + let def = s + .definitions + .iter() + .find(|d| d.name == name) + .unwrap_or_else(|| panic!("{name} should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + assert!(!s.definitions.iter().any(|d| d.name == "[a, b]")); + } + + #[test] + fn extracts_array_pattern_default_and_rest_bindings_as_own_definitions() { + // #1901: array-pattern default-value and rest bindings each become + // their own "constant" Definition, matching the plain-identifier case. + let s = parse_js("const [a = 1, ...rest] = computeList();"); + for name in ["a", "rest"] { + let def = s + .definitions + .iter() + .find(|d| d.name == name) + .unwrap_or_else(|| panic!("{name} should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + } + + #[test] + fn const_alias_gets_both_definition_and_fn_ref_binding() { + // The new "constant" Definition for an identifier-aliased const must not + // come at the expense of the existing pts fn_ref_binding tracking — the + // two concerns are independent (mirrors the WASM/TS extractor's + // decoupled fnRefBindings pass). + let s = parse_js("const alias = handler;"); + assert!( + s.definitions.iter().any(|d| d.name == "alias" && d.kind == "constant"), + "alias should be extracted as a constant definition; got: {:?}", + s.definitions + ); + assert!( + s.fn_ref_bindings.iter().any(|b| b.lhs == "alias" && b.rhs == "handler"), + "alias -> handler fn_ref_binding should still be recorded; got: {:?}", + s.fn_ref_bindings + ); + } + // ── AST node extraction tests ──────────────────────────────────────────── #[test] @@ -3870,12 +4975,25 @@ mod tests { #[test] fn finds_dynamic_import_with_aliased_destructuring() { + // #1824: the local binding actually referenced by call sites + // (`fromBarrel`) must be recorded in `names`, not the name exported by + // the target module (`buildGraph`) — mirrors the static + // `import { X as Y }` fix from #1730. `renamed_imports` carries the + // local → original mapping so call-edge resolution can still find + // `buildGraph` in the target file. let s = parse_js("const { buildGraph: fromBarrel } = await import('./builder.js');"); let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); assert_eq!(dyn_imports.len(), 1); assert_eq!(dyn_imports[0].source, "./builder.js"); - assert!(dyn_imports[0].names.contains(&"buildGraph".to_string())); - assert!(!dyn_imports[0].names.contains(&"fromBarrel".to_string())); + assert!(dyn_imports[0].names.contains(&"fromBarrel".to_string())); + assert!(!dyn_imports[0].names.contains(&"buildGraph".to_string())); + let renamed = dyn_imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for a renamed destructure"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "fromBarrel"); + assert_eq!(renamed[0].imported, "buildGraph"); } #[test] @@ -3885,9 +5003,16 @@ mod tests { assert_eq!(dyn_imports.len(), 1); assert_eq!(dyn_imports[0].source, "./mod.js"); assert!(dyn_imports[0].names.contains(&"a".to_string())); - assert!(dyn_imports[0].names.contains(&"buildGraph".to_string())); + assert!(dyn_imports[0].names.contains(&"fromBarrel".to_string())); assert!(dyn_imports[0].names.contains(&"c".to_string())); - assert!(!dyn_imports[0].names.contains(&"fromBarrel".to_string())); + assert!(!dyn_imports[0].names.contains(&"buildGraph".to_string())); + let renamed = dyn_imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for a renamed destructure"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "fromBarrel"); + assert_eq!(renamed[0].imported, "buildGraph"); } #[test] @@ -3895,8 +5020,15 @@ mod tests { let s = parse_js("const { buildGraph: local = null } = await import('./builder.js');"); let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); assert_eq!(dyn_imports.len(), 1); - assert!(dyn_imports[0].names.contains(&"buildGraph".to_string())); - assert!(!dyn_imports[0].names.contains(&"local".to_string())); + assert!(dyn_imports[0].names.contains(&"local".to_string())); + assert!(!dyn_imports[0].names.contains(&"buildGraph".to_string())); + let renamed = dyn_imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for a renamed destructure"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "local"); + assert_eq!(renamed[0].imported, "buildGraph"); } #[test] @@ -3908,6 +5040,94 @@ mod tests { assert!(!dyn_imports[0].names.contains(&"nested".to_string())); } + // Regression tests for #1781: `codegraph exports` failed to credit consumers + // reached via `const { X } = (await import('./mod.js')) as {...}` — the + // walk-up from the import() call to its enclosing variable_declarator only + // skipped a single optional await_expression, so the extra + // parenthesized_expression / as_expression layers introduced by wrapping + // parens and a TS type-assertion caused name extraction to bail out with + // an empty list, exactly as if the destructured names couldn't be + // determined at all. + + #[test] + fn finds_dynamic_import_with_parenthesized_destructuring() { + let s = parse_ts("const { a, b } = (await import('./foo.js'));"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert!(dyn_imports[0].names.contains(&"a".to_string())); + assert!(dyn_imports[0].names.contains(&"b".to_string())); + } + + #[test] + fn finds_dynamic_import_with_as_cast_destructuring() { + let s = parse_ts("const { a, b } = await import('./foo.js') as { a: Fn; b: Fn };"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert!(dyn_imports[0].names.contains(&"a".to_string())); + assert!(dyn_imports[0].names.contains(&"b".to_string())); + } + + #[test] + fn finds_dynamic_import_with_parenthesized_as_cast_destructuring() { + // Exact repro shape from #1781 (native-orchestrator.ts): + // `const { X, Y } = (await import('../mod.js')) as { X: Fn; Y: Fn };` + let s = parse_ts( + "const { buildDataflowVerticesFromMap, buildDataflowEdges } = (await import('../../../../features/dataflow.js')) as { buildDataflowVerticesFromMap: Fn; buildDataflowEdges: Fn };", + ); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert_eq!(dyn_imports[0].source, "../../../../features/dataflow.js"); + assert!(dyn_imports[0].names.contains(&"buildDataflowVerticesFromMap".to_string())); + assert!(dyn_imports[0].names.contains(&"buildDataflowEdges".to_string())); + } + + // Regression tests for #1920: `extract_rest_identifier` indexed into a + // fixed child slot (0) that is actually the `...` token, not the bound + // identifier, so rest elements in both object- and array-pattern + // destructures of a dynamic `import()` were silently dropped. + + #[test] + fn finds_dynamic_import_with_object_rest_destructuring() { + let s = parse_js("const { a, ...rest } = await import('./mod.js');"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert_eq!(dyn_imports[0].names, vec!["a".to_string(), "rest".to_string()]); + } + + #[test] + fn finds_dynamic_import_with_shorthand_default_destructuring() { + let s = parse_js("const { a = 1 } = await import('./mod.js');"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert_eq!(dyn_imports[0].names, vec!["a".to_string()]); + } + + #[test] + fn finds_dynamic_import_with_mixed_plain_renamed_default_and_rest_destructuring() { + let s = parse_js("const { a, b: alias, c = 1, ...rest } = await import('./mod.js');"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert_eq!( + dyn_imports[0].names, + vec!["a".to_string(), "alias".to_string(), "c".to_string(), "rest".to_string()] + ); + let renamed = dyn_imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for the renamed specifier"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "alias"); + assert_eq!(renamed[0].imported, "b"); + } + + #[test] + fn finds_dynamic_import_with_array_rest_destructuring() { + let s = parse_js("const [a, ...rest] = await import('./mod.js');"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert_eq!(dyn_imports[0].names, vec!["a".to_string(), "rest".to_string()]); + } + #[test] fn extracts_callback_reference_in_router_use() { let s = parse_js("router.use(handleToken);"); @@ -3954,6 +5174,306 @@ mod tests { assert!(dynamic_calls.is_empty()); } + // ── #1778: .call/.apply/.bind reflection tagging (parity pin) ─────────── + // + // Pins the native extractor's classification of `.call/.apply/.bind` on both + // identifier and member-expression receivers as dynamic/reflection. This is + // the Option-A semantic from #1778: the WASM extractor previously stripped + // this tag for identifier receivers only, diverging from native. These tests + // guard against either engine's classification drifting again — the + // dedup-collision case that originally motivated the WASM regression (#1687) + // is a downstream build-edges.ts concern, not an extraction concern, so it is + // deliberately NOT re-tested here (see the JS-side pins in + // tests/integration for that). + + #[test] + fn call_on_identifier_receiver_tags_reflection() { + let s = parse_js("function test(ctx) { greet.call(ctx, 'world'); }"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn apply_on_identifier_receiver_tags_reflection() { + let s = parse_js("function test(ctx) { greet.apply(ctx, ['world']); }"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn bind_on_identifier_receiver_tags_reflection() { + let s = parse_js("var bound = greet.bind(ctx);"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn call_on_member_expression_receiver_tags_reflection() { + let s = parse_js("obj.method.call({});"); + let c = s.calls.iter().find(|c| c.name == "method").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + // ── #1817: get_first_call_arg consumers (eval/Reflect.apply/Reflect.construct) ── + // + // Exercises the three call sites that resolve their first call argument via + // `get_first_call_arg`, which used to take an unused `source` parameter. + + #[test] + fn eval_captures_string_literal_key_expr() { + let s = parse_js("function test() { eval(\"console.log('hi')\"); }"); + let c = s.calls.iter().find(|c| c.name == "").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("eval")); + assert!(c.key_expr.as_deref().unwrap().contains("console.log")); + } + + #[test] + fn eval_with_non_literal_arg_has_no_key_expr() { + let s = parse_js("function test(code) { eval(code); }"); + let c = s.calls.iter().find(|c| c.name == "").unwrap(); + assert_eq!(c.dynamic_kind.as_deref(), Some("eval")); + assert!(c.key_expr.is_none()); + } + + #[test] + fn reflect_apply_extracts_first_arg_as_callee() { + let s = parse_js("function test(fn, ctx) { Reflect.apply(fn, ctx, []); }"); + let c = s.calls.iter().find(|c| c.name == "fn").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn reflect_construct_extracts_first_arg_as_callee() { + let s = parse_js("function test(Cls) { Reflect.construct(Cls, []); }"); + let c = s.calls.iter().find(|c| c.name == "Cls").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn reflect_apply_member_expression_arg_extracts_property_with_receiver() { + let s = parse_js("function test(obj, ctx) { Reflect.apply(obj.method, ctx, []); }"); + let c = s.calls.iter().find(|c| c.name == "method").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + assert_eq!(c.receiver.as_deref(), Some("obj")); + } + + // ── #1771: object-literal value-ref extraction ────────────────────────── + + #[test] + fn extracts_value_ref_call_for_object_literal_property() { + let s = parse_js("const table = { resolve: resolveWrapperParam };"); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "resolveWrapperParam")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_calls_for_every_handler_in_dispatch_table_array() { + // Mirrors this repo's own PARAM_NODE_HANDLERS pattern (issue #1771). + let s = parse_js( + "const HANDLERS = [\n\ + { matches: isA, resolve: resolveA },\n\ + { matches: isB, resolve: resolveB },\n\ + ];", + ); + let names: Vec<&str> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .map(|c| c.name.as_str()) + .collect(); + for expected in ["isA", "resolveA", "isB", "resolveB"] { + assert!(names.contains(&expected), "missing value-ref call for {}", expected); + } + } + + #[test] + fn extracts_value_ref_call_for_shorthand_property() { + let s = parse_js("const table = { someFunction };"); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "someFunction")); + } + + // #1895: key_expr capture — the property key, distinct from the + // referenced value's own name, is what a dispatch consumer would + // actually call (`table.resolve(...)`). + #[test] + fn value_ref_captures_property_key_distinct_from_referenced_name() { + let s = parse_js("const table = { resolve: someFunction };"); + let call = s + .calls + .iter() + .find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction") + .expect("expected a value-ref call"); + assert_eq!(call.key_expr.as_deref(), Some("resolve")); + } + + #[test] + fn value_ref_captures_string_literal_key_with_quotes_stripped() { + let s = parse_js("const table = { 'resolve': someFunction };"); + let call = s + .calls + .iter() + .find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction") + .expect("expected a value-ref call"); + assert_eq!(call.key_expr.as_deref(), Some("resolve")); + } + + #[test] + fn value_ref_captures_computed_string_literal_key() { + let s = parse_js("const table = { ['resolve']: someFunction };"); + let call = s + .calls + .iter() + .find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction") + .expect("expected a value-ref call"); + assert_eq!(call.key_expr.as_deref(), Some("resolve")); + } + + #[test] + fn value_ref_leaves_key_expr_unset_for_non_string_computed_key() { + let s = parse_js("const table = { [Symbol.iterator]: someFunction };"); + let call = s + .calls + .iter() + .find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction") + .expect("expected a value-ref call"); + assert_eq!(call.key_expr, None); + } + + #[test] + fn value_ref_key_expr_equals_name_for_shorthand_property() { + let s = parse_js("const table = { someFunction };"); + let call = s + .calls + .iter() + .find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "someFunction") + .expect("expected a value-ref call"); + assert_eq!(call.key_expr.as_deref(), Some("someFunction")); + } + + #[test] + fn instanceof_value_ref_leaves_key_expr_unset() { + let s = parse_js("if (err instanceof CodegraphError) {}"); + let call = s + .calls + .iter() + .find(|c| c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "CodegraphError") + .expect("expected a value-ref call"); + assert_eq!(call.key_expr, None); + } + + #[test] + fn no_value_ref_call_for_call_expression_value() { + let s = parse_js("const table = { resolve: someFunction() };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_member_expression_value() { + let s = parse_js("const table = { resolve: obj.someFunction };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_inline_function_value() { + let s = parse_js("const table = { resolve: () => {}, other: function () {} };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_literal_or_data_shaped_values() { + let s = parse_js( + "const config = { name: 'literal', count: 42, active: true, empty: null, list: [1, 2] };", + ); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_builtin_globals() { + let s = parse_js("const table = { log: console, Ctor: Object };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + // ── #1784: instanceof value-ref extraction ────────────────────────────── + + #[test] + fn extracts_value_ref_call_for_instanceof_class_name() { + let s = parse_js( + "function handle(err) { if (err instanceof CodegraphError) { report(err); } }", + ); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "CodegraphError")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_call_for_instanceof_as_expression_value() { + let s = parse_js("const isConfig = (err) => err instanceof ConfigError;"); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "ConfigError")); + } + + #[test] + fn no_value_ref_call_for_instanceof_member_expression_operand() { + let s = parse_js("const check = (a) => a instanceof ns.SomeClass;"); + assert!( + s.calls + .iter() + .all(|c| !(c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "SomeClass")) + ); + } + + #[test] + fn no_value_ref_call_for_instanceof_call_expression_operand() { + let s = parse_js("const check = (a) => a instanceof getClass();"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_instanceof_builtin_globals() { + let s = parse_js( + "function isBuiltin(x) { return x instanceof Error || x instanceof Array || x instanceof Map; }", + ); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_in_operator() { + let s = parse_js("const has = (obj) => 'key' in obj;"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_other_binary_operators() { + let s = parse_js("const sum = (a, b) => a + b === Total;"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + #[test] fn no_duplicate_call_for_call_expression_arg() { let s = parse_js("router.use(checkPermissions(['admin']));"); @@ -4047,6 +5567,264 @@ mod tests { assert_eq!(cb.unwrap().receiver.as_deref(), Some("handlers")); } + #[test] + fn no_identifier_callback_for_non_allowlisted_callee_issue_1741() { + // Regression guard for #1741: `findMergeCandidates(communities)` and + // `analyzeDrift(communities, communityDirs)` pass `communities` as a + // plain DATA argument, not a callback reference. Neither + // `findMergeCandidates` nor `analyzeDrift` is a callback-accepting + // callee, so identifier args must be gated exactly like + // member_expression args — otherwise the global-fallback resolver + // can bind the identifier to an unrelated same-named function + // elsewhere in the repo, fabricating a call edge (and, transitively, + // a phantom cycle — see codegraph's own src/features/communities.ts + // vs src/presentation/communities.ts). + let s = parse_js("findMergeCandidates(communities);"); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "communities"), + "findMergeCandidates non-allowlisted callee must not emit `communities` as dynamic call; got: {:?}", + s.calls, + ); + + let s2 = parse_js("analyzeDrift(communities, communityDirs);"); + assert!( + !s2.calls.iter().any(|c| c.dynamic == Some(true)), + "analyzeDrift non-allowlisted callee must not emit any dynamic calls; got: {:?}", + s2.calls, + ); + } + + #[test] + fn emits_identifier_callback_for_allowlisted_callee_issue_1741() { + // Positive companion to the #1741 fix: identifier args passed to a + // genuine callback-accepting callee must still be resolved, e.g. + // `arr.forEach(myNamedCallback)` — the exact pattern the original + // "identifier args are always emitted" trade-off existed to preserve. + let s = parse_js("arr.forEach(myNamedCallback);"); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "myNamedCallback"), + "arr.forEach must still emit myNamedCallback as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn recognizes_identifier_arg_via_function_shaped_type_alias_issue_1845() { + // Regression guard for #1845: `processEach`'s `fn` param is typed with a + // function-shaped type alias (`UserProcessor`), so `logUser` must be + // recognized as a callback reference even though neither `processEach` + // nor `logUser` is in CALLBACK_ACCEPTING_CALLEES. + let s = parse_ts( + "type UserProcessor = (user: string) => void; + function processEach(users: string[], fn: UserProcessor): void { + for (const user of users) fn(user); + } + function logUser(user: string): void { console.log(user); } + function runDemo(users: string[]): void { + processEach(users, logUser); + }", + ); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "logUser"), + "processEach(users, logUser) must emit logUser as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn recognizes_identifier_arg_via_inline_arrow_function_type_issue_1845() { + let s = parse_ts( + "function processEach(users: string[], fn: (user: string) => void): void { + for (const user of users) fn(user); + } + function logUser(user: string): void {} + function runDemo(users: string[]): void { + processEach(users, logUser); + }", + ); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "logUser"), + "inline arrow-function-type param must recognize logUser; got: {:?}", + s.calls, + ); + } + + #[test] + fn recognizes_identifier_arg_via_function_typed_param_issue_1845() { + let s = parse_ts( + "function runWith(fn: Function): void { fn(); } + function handler(): void {} + function runDemo(): void { + runWith(handler); + }", + ); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "handler"), + "Function-typed param must recognize handler; got: {:?}", + s.calls, + ); + } + + #[test] + fn does_not_recognize_identifier_arg_when_param_not_function_shaped_issue_1845() { + // Regression guard: the new type-based gate must not reintroduce the + // #1741 false positive for callees whose parameter is plain data. + let s = parse_ts( + "function findMergeCandidates(communities: string[]): void {} + function runDemo(communities: string[]): void { + findMergeCandidates(communities); + }", + ); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "communities"), + "non-function-shaped param must not emit communities as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn only_recognizes_function_shaped_param_position_issue_1845() { + let s = parse_ts( + "type UserPredicate = (user: string) => boolean; + type UserProcessor = (user: string) => void; + function filterThen(users: string[], pred: UserPredicate, fn: UserProcessor): void {} + function hasEmail(user: string): boolean { return true; } + function logUser(user: string): void {} + function runDemo(users: string[]): void { + filterThen(users, hasEmail, logUser); + }", + ); + let dynamic_names: Vec<&str> = s.calls.iter() + .filter(|c| c.dynamic == Some(true)) + .map(|c| c.name.as_str()) + .collect(); + assert!(dynamic_names.contains(&"hasEmail"), "got: {:?}", s.calls); + assert!(dynamic_names.contains(&"logUser"), "got: {:?}", s.calls); + assert!(!dynamic_names.contains(&"users"), "got: {:?}", s.calls); + } + + #[test] + fn resolves_one_level_of_type_alias_indirection_issue_1845() { + let s = parse_ts( + "type Handler = (user: string) => void; + type UserProcessor = Handler; + function processEach(users: string[], fn: UserProcessor): void {} + function logUser(user: string): void {} + function runDemo(users: string[]): void { + processEach(users, logUser); + }", + ); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "logUser"), + "one-level alias indirection must still recognize logUser; got: {:?}", + s.calls, + ); + } + + #[test] + fn recognizes_function_shaped_param_on_class_methods_issue_1845() { + let s = parse_ts( + "class Runner { + processEach(users: string[], fn: (user: string) => void): void {} + } + function logUser(user: string): void {} + function runDemo(runner: Runner, users: string[]): void { + runner.processEach(users, logUser); + }", + ); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "logUser"), + "class-method function-shaped param must recognize logUser; got: {:?}", + s.calls, + ); + } + + #[test] + fn does_not_misalign_param_index_with_explicit_this_param_issue_1845() { + // TypeScript's explicit `this` parameter is compiled away and never + // appears at the call site — it must not consume an argument-index slot. + let s = parse_ts( + "function processEach(this: void, users: string[], fn: (user: string) => void): void {} + function logUser(user: string): void {} + function runDemo(users: string[]): void { + processEach(users, logUser); + }", + ); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "logUser"), + "explicit this param must not misalign the callback param index; got: {:?}", + s.calls, + ); + } + + #[test] + fn no_identifier_callback_for_cache_or_map_get() { + // Identifier-arg counterpart to `no_member_expr_callback_for_cache_or_map_get`: + // `cache.get(someKey)` shares the verb name `get` with Express routes + // but has no string-literal route path first arg, so the identifier + // arg must not be emitted as a dynamic call either. + let s = parse_js("cache.get(someKey);"); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "someKey"), + "cache.get(someKey) must not emit `someKey` as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn emits_array_from_mapfn_but_not_arraylike() { + // Regression guard for #1741 follow-up: `Array.from(arrayLike, mapFn)` is + // a well-known stdlib callback pattern (also every TypedArray.from), but + // the callback is the SECOND positional argument, not the first. Emitting + // `arrayLike` too would reintroduce the exact name-collision false-positive + // class #1741 fixes for the data argument; only `mapFn` should resolve. + let s = parse_js("Array.from(arr, mapCallback);"); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "mapCallback"), + "Array.from(arr, mapCallback) must emit mapCallback as dynamic call; got: {:?}", + s.calls, + ); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "arr"), + "Array.from(arr, mapCallback) must not emit `arr` (index 0) as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn emits_only_index_one_for_array_from_with_this_arg() { + // `Array.from(arrayLike, mapFn, thisArg)` — thisArg (index 2) is a `this` + // binding context, not a callback, and must not be emitted either. + let s = parse_js("Array.from(arr, mapCallback, thisArg);"); + let dynamic_names: Vec<&str> = s.calls.iter() + .filter(|c| c.dynamic == Some(true)) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!( + dynamic_names, vec!["mapCallback"], + "only index-1 mapCallback should be dynamic; got: {:?}", s.calls, + ); + } + + #[test] + fn applies_array_from_positional_gate_to_typed_array_constructors() { + // Every TypedArray constructor (Uint8Array, Int32Array, etc.) mirrors + // Array.from's (arrayLike, mapFn, thisArg) signature; the gate is + // name-based on the property `from`, not receiver-typed, so it applies + // uniformly. + let s = parse_js("Uint8Array.from(arr, mapCallback);"); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "mapCallback"), + "Uint8Array.from(arr, mapCallback) must emit mapCallback; got: {:?}", + s.calls, + ); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "arr"), + "Uint8Array.from(arr, mapCallback) must not emit `arr`; got: {:?}", + s.calls, + ); + } + #[test] fn no_dynamic_call_for_dynamic_import_arg() { // Parity with TS walk path: callback-reference extraction must be skipped @@ -4062,12 +5840,33 @@ mod tests { #[test] fn extracts_destructured_const_bindings() { + // kind is "constant" (#1773), not "function" — matches the plain + // `const x = ` and array-pattern destructuring convention. + // Destructured names remain resolvable as call targets regardless of + // kind (call-target resolution is kind-agnostic), so callback-style + // destructured bindings like `handleToken` still resolve when called. let s = parse_js("const { handleToken, checkPermissions } = initAuth(config);"); let names: Vec<&str> = s.definitions.iter().map(|d| d.name.as_str()).collect(); assert!(names.contains(&"handleToken"), "should extract handleToken definition"); assert!(names.contains(&"checkPermissions"), "should extract checkPermissions definition"); let ht = s.definitions.iter().find(|d| d.name == "handleToken").unwrap(); - assert_eq!(ht.kind, "function"); + assert_eq!(ht.kind, "constant"); + } + + #[test] + fn extracts_non_renamed_destructured_bindings_with_kind_constant() { + // Regression guard for issue #1773: plain (non-renamed) destructured + // bindings from a non-call RHS (e.g. `workerData`) must not default to + // kind "function" — they hold arbitrary values, not callables. + let s = parse_js("const { dbPath, name, force } = workerData;"); + for expected in ["dbPath", "name", "force"] { + let def = s + .definitions + .iter() + .find(|d| d.name == expected) + .unwrap_or_else(|| panic!("should extract {expected} definition")); + assert_eq!(def.kind, "constant"); + } } #[test] @@ -4108,7 +5907,13 @@ mod tests { #[test] fn extracts_renamed_destructured_binding() { let s = parse_js("const { original: renamed } = initAuth();"); - assert!(s.definitions.iter().any(|d| d.name == "renamed"), "should use the local alias"); + let renamed = s + .definitions + .iter() + .find(|d| d.name == "renamed") + .expect("should use the local alias"); + // kind is "constant" (#1773) — see comment on extracts_destructured_const_bindings. + assert_eq!(renamed.kind, "constant"); assert!(!s.definitions.iter().any(|d| d.name == "original"), "should not use the original key"); } @@ -4556,11 +6361,11 @@ mod tests { } /// Object literal shorthand method `{ f() {} }` must produce BOTH a bare `f(method)` node - /// (from handle_method_def, main walk) AND a qualified `o1.f(function)` node (from the - /// second-pass match_js_objlit_qualified_method_defs), with the bare node appearing FIRST. - /// findCaller's equal-span tie-break keeps the first entry, so `f(method)` wins for call - /// attribution — matching WASM where handleMethodCapture runs before extractObjectLiteralFunctions. - /// Issue #1538. + /// AND a qualified `o1.f(function)` node — both emitted inline together by + /// extract_object_literal_functions (see is_object_literal_declarator_method), with the + /// bare node appearing FIRST. findCaller's equal-span tie-break keeps the first entry, so + /// `f(method)` wins for call attribution — matching WASM's extractObjectLiteralFunctions, + /// which emits both in the same relative order. Issue #1538, #1818. #[test] fn object_literal_shorthand_method_bare_node_precedes_qualified() { let s = parse_js( @@ -4594,6 +6399,181 @@ mod tests { assert_eq!(tm_f.unwrap().type_name, "f"); } + /// Issue #1764: a computed string-literal pair key (`['foo']: () => {}`) must resolve to + /// the plain qualified name `obj.foo`, not the raw bracket/quote text `obj.['foo']` — the + /// same unwrapping `resolve_method_def_name` already applies to method_definition keys. + #[test] + fn computed_string_literal_pair_key_resolves_to_plain_name() { + let s = parse_js( + "const obj = {\n\ + ['foo']: () => { return 1; },\n\ + bar: () => { return 2; },\n\ + };\n\ + obj.foo();\n\ + obj.bar();", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"obj.foo"), "expected 'obj.foo'; got: {:?}", names); + assert!(names.contains(&"obj.bar"), "expected 'obj.bar'; got: {:?}", names); + assert!( + !names.iter().any(|n| n.contains('[')), + "no definition name should retain the bracketed/quoted form; got: {:?}", + names + ); + } + + /// Issue #1764: a non-string computed pair key (`[Symbol.iterator]: () => {}`) has no + /// statically resolvable name — the pair must be skipped entirely, mirroring + /// method_definition's existing precedent for the same computed-key shape. + #[test] + fn non_string_computed_pair_key_is_skipped() { + let s = parse_js( + "const obj = {\n\ + [Symbol.iterator]: () => { return 1; },\n\ + bar: () => { return 2; },\n\ + };", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("iterator")), + "non-string computed key must not produce a definition; got: {:?}", + names + ); + assert!(names.contains(&"obj.bar"), "expected 'obj.bar'; got: {:?}", names); + } + + /// Issue #1764: the same computed-key unwrapping must apply to `let`/`var` object literals, + /// not just `const` — both now go through extract_object_literal_functions inline. + #[test] + fn computed_string_literal_pair_key_resolves_for_let_and_var() { + let s = parse_js( + "let obj2 = { ['computedLet']: () => {}, plain: () => {} };\n\ + var obj3 = { ['computedVar']: () => {} };", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"obj2.computedLet"), "expected 'obj2.computedLet'; got: {:?}", names); + assert!(names.contains(&"obj2.plain"), "expected 'obj2.plain'; got: {:?}", names); + assert!(names.contains(&"obj3.computedVar"), "expected 'obj3.computedVar'; got: {:?}", names); + } + + /// Issue #1884: `seed_object_create_entries`'s pair arm must unwrap a computed + /// string-literal key (`Object.create({ ['foo']: fn })`) instead of falling back to the + /// raw bracket/quote source text. + #[test] + fn computed_key_in_object_create_resolves() { + let s = parse_js( + "function fn() {}\n\ + const obj = Object.create({ ['foo']: fn });", + ); + let entry = s.type_map.iter().find(|e| e.name == "obj.foo"); + assert!(entry.is_some(), "type_map should contain 'obj.foo'; got: {:?}", s.type_map); + assert_eq!(entry.unwrap().type_name, "fn"); + } + + /// Issue #1884: `seed_descriptor_object`'s pair arm (Object.defineProperties) must unwrap + /// a computed string-literal key instead of falling back to the raw bracket/quote text. + #[test] + fn computed_key_in_define_properties_resolves() { + let s = parse_js( + "function f1() {}\n\ + const obj = {};\n\ + Object.defineProperties(obj, { ['foo']: { value: f1 } });", + ); + let entry = s.type_map.iter().find(|e| e.name == "obj.foo"); + assert!(entry.is_some(), "type_map should contain 'obj.foo'; got: {:?}", s.type_map); + assert_eq!(entry.unwrap().type_name, "f1"); + } + + /// Issue #1884: a non-string computed key in Object.defineProperties has no statically + /// resolvable name — must be skipped rather than emitting a garbled entry. + #[test] + fn non_string_computed_key_in_define_properties_skipped() { + let s = parse_js( + "function f1() {}\n\ + const obj = {};\n\ + Object.defineProperties(obj, { [Symbol.iterator]: { value: f1 } });", + ); + assert!( + !s.type_map.iter().any(|e| e.name.contains("Symbol")), + "non-string computed key must not produce a type_map entry; got: {:?}", s.type_map + ); + } + + /// Issue #1884: `seed_objlit_type_map_entries`'s pair arm (let/var object literals) must + /// unwrap a computed string-literal key instead of falling back to the raw bracket/quote text. + #[test] + fn computed_key_in_let_objlit_pair_seeds_type_map() { + let s = parse_js( + "function handler() {}\n\ + var routes = { ['get']: handler };", + ); + let entry = s.type_map.iter().find(|e| e.name == "routes.get"); + assert!(entry.is_some(), "type_map should contain 'routes.get'; got: {:?}", s.type_map); + assert_eq!(entry.unwrap().type_name, "handler"); + } + + /// Issue #1884: `extract_js_prototype_object_literal`'s pair arm must unwrap a computed + /// string-literal key instead of falling back to the raw bracket/quote text. + #[test] + fn computed_key_in_prototype_object_literal_pair_resolves() { + let s = parse_js( + "function helper() {}\n\ + function C() {}\n\ + C.prototype = { ['run']: helper };", + ); + let entry = s.type_map.iter().find(|e| e.name == "C.run"); + assert!(entry.is_some(), "type_map should contain 'C.run'; got: {:?}", s.type_map); + assert_eq!(entry.unwrap().type_name, "helper"); + } + + /// Issue #1884: a computed string-literal pair key with a function value in a prototype + /// object literal must emit a method definition under the plain qualified name. + #[test] + fn computed_key_in_prototype_object_literal_pair_fn_value_emits_definition() { + let s = parse_js( + "function C() {}\n\ + C.prototype = { ['foo']: function() { return 1; } };", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"C.foo"), "expected 'C.foo'; got: {:?}", names); + } + + /// Issue #1884: `collect_object_rest_params`'s pair arm previously skipped ALL computed + /// keys, including resolvable string literals — it must now unwrap them the same way + /// `resolve_pair_key_name` does elsewhere, instead of blanket-skipping. + #[test] + fn computed_string_literal_key_unwrapped_for_object_rest_param_binding() { + let s = parse_js( + "const api = {\n\ + ['process']: function({ items, ...rest }) {\n\ + rest.flush();\n\ + }\n\ + };", + ); + let b = s.object_rest_param_bindings.iter().find(|b| b.callee == "process"); + assert!(b.is_some(), "object_rest_param_bindings missing; got: {:?}", s.object_rest_param_bindings); + let b = b.unwrap(); + assert_eq!(b.rest_name, "rest"); + assert_eq!(b.arg_index, 0); + } + + /// Issue #1884: a non-string computed key must still be skipped for rest-param binding + /// extraction — there's no statically resolvable callee name to bind against. + #[test] + fn non_string_computed_key_still_skipped_for_object_rest_param_binding() { + let s = parse_js( + "const api = {\n\ + [Symbol.iterator]: function({ ...rest }) {\n\ + rest.flush();\n\ + }\n\ + };", + ); + assert!( + !s.object_rest_param_bindings.iter().any(|b| b.rest_name == "rest"), + "non-string computed key must not produce a binding; got: {:?}", s.object_rest_param_bindings + ); + } + /// Issue #1551: `let` and `var` object-literal declarations must seed composite typeMap keys /// just like `const` declarations. Regression test for the parity gap where native bailed /// early for non-`const` declarations in the object-literal typeMap walk. @@ -4635,8 +6615,8 @@ mod tests { assert_eq!(tm3.unwrap().type_name, "handler"); // Pair with arrow value: `let api = { save: () => {} }` → typeMap['api.save'] = 'api.save' - // and a qualified definition 'api.save' must exist (emitted by the deferred - // match_js_objlit_qualified_method_defs pass for non-const pair+arrow/function). + // and a qualified definition 'api.save' must exist (emitted inline by + // extract_object_literal_functions, called from handle_var_decl's let/var branch). let s_let_arrow = parse_js( "let api = { save: () => {} };\n\ api.save();", @@ -4839,6 +6819,29 @@ mod tests { ); } + /// Bare `super(...)` must be extracted as a `constructor` call with + /// receiver `super`, mirroring `super.method()` — the this/super hierarchy + /// dispatch (WASM-mirrored `resolveThisDispatch`) then attributes it to the + /// parent class's constructor (#1929). + #[test] + fn bare_super_call_extracted_as_constructor_call() { + let s = parse_js( + "class E { constructor(c) { this.cc = c; } }\n\ + class G extends E {\n\ + constructor(a) { super(a); }\n\ + }", + ); + let super_call = s + .calls + .iter() + .find(|c| c.name == "constructor" && c.receiver.as_deref() == Some("super")); + assert!( + super_call.is_some(), + "bare super(...) must be recorded as a constructor call with receiver=super; got: {:?}", + s.calls.iter().map(|c| (&c.name, &c.receiver)).collect::>() + ); + } + #[test] fn array_elem_bindings_recorded() { let s = parse_js( @@ -5034,4 +7037,116 @@ mod tests { let dt_call = s.calls.iter().find(|c| c.name.starts_with("[*]")); assert!(dt_call.is_some(), "dispatch-table call missing for parenthesized object; got: {:?}", s.calls); } + + // ── ES6 getter/setter same-file property-read call attribution (#1893) ── + + #[test] + fn attributes_bare_this_prop_read_to_same_class_getter() { + let s = parse_js( + "class Session {\n\ + get isReady() { return this._ready; }\n\ + check() { if (this.isReady) { report(); } }\n\ + }", + ); + assert!( + s.calls.iter().any(|c| c.name == "isReady" && c.receiver.as_deref() == Some("this")), + "expected a call to isReady via this; got: {:?}", + s.calls + ); + } + + #[test] + fn attributes_bare_varname_prop_read_to_same_file_class_getter_via_type_map() { + let s = parse_ts( + "class Repo {\n\ + get db() { return this._db; }\n\ + }\n\ + function useRepo(repo: Repo) {\n\ + return repo.db;\n\ + }", + ); + assert!( + s.calls.iter().any(|c| c.name == "db" && c.receiver.as_deref() == Some("repo")), + "expected a call to db via repo; got: {:?}", + s.calls + ); + } + + #[test] + fn attributes_plain_assignment_write_to_same_class_setter() { + let s = parse_js( + "class Toggle {\n\ + set flag(v) { this._f = v; }\n\ + reset() { this.flag = false; }\n\ + }", + ); + assert!( + s.calls.iter().any(|c| c.name == "flag" && c.receiver.as_deref() == Some("this")), + "expected a call to flag via this; got: {:?}", + s.calls + ); + } + + #[test] + fn skips_property_with_both_getter_and_setter() { + let s = parse_js( + "class Toggle {\n\ + get flag() { return this._f; }\n\ + set flag(v) { this._f = v; }\n\ + flip() { this.flag = !this.flag; }\n\ + }", + ); + assert!( + !s.calls.iter().any(|c| c.name == "flag"), + "ambiguous get+set accessor must not produce a call; got: {:?}", + s.calls + ); + } + + #[test] + fn does_not_duplicate_a_real_call_to_an_accessor_name() { + let s = parse_js( + "class Widget {\n\ + get value() { return this._v; }\n\ + }\n\ + function useWidget(w) {\n\ + return w.value();\n\ + }", + ); + let matches = s.calls.iter().filter(|c| c.name == "value" && c.receiver.as_deref() == Some("w")).count(); + assert_eq!(matches, 1, "expected exactly one call to w.value(); got: {:?}", s.calls); + } + + #[test] + fn does_not_attribute_plain_method_reference_as_call() { + let s = parse_js( + "class Widget {\n\ + render() { return 1; }\n\ + }\n\ + function useWidget(w) {\n\ + const fn = w.render;\n\ + return fn;\n\ + }", + ); + assert!( + !s.calls.iter().any(|c| c.name == "render" && c.receiver.as_deref() == Some("w")), + "plain method reference (no accessor) must not produce a call; got: {:?}", + s.calls + ); + } + + #[test] + fn recognizes_static_accessor_same_as_instance() { + let s = parse_js( + "class Config {\n\ + static get version() { return Config._v; }\n\ + static describe() { return this.version; }\n\ + }", + ); + assert!( + s.calls.iter().any(|c| c.name == "version" && c.receiver.as_deref() == Some("this")), + "expected a call to version via this; got: {:?}", + s.calls + ); + } } diff --git a/crates/codegraph-core/src/extractors/julia.rs b/crates/codegraph-core/src/extractors/julia.rs index 237bbd91a..7817be22f 100644 --- a/crates/codegraph-core/src/extractors/julia.rs +++ b/crates/codegraph-core/src/extractors/julia.rs @@ -67,6 +67,7 @@ fn handle_module_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) -> O complexity: None, cfg: None, children: None, + bodyless: None, }); Some(name) @@ -118,6 +119,7 @@ fn handle_function_def( complexity: compute_all_metrics(node, source, "julia"), cfg: build_function_cfg(node, "julia", source), children: opt_children(params), + bodyless: None, }); return; } @@ -145,6 +147,7 @@ fn handle_function_def( complexity: compute_all_metrics(node, source, "julia"), cfg: build_function_cfg(node, "julia", source), children: None, + bodyless: None, }); } @@ -185,6 +188,7 @@ fn handle_assignment( complexity: compute_all_metrics(node, source, "julia"), cfg: build_function_cfg(node, "julia", source), children: opt_children(params), + bodyless: None, }); } @@ -266,6 +270,7 @@ fn handle_struct_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } @@ -298,6 +303,7 @@ fn handle_abstract_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -376,6 +382,7 @@ fn handle_macro_def( complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/kotlin.rs b/crates/codegraph-core/src/extractors/kotlin.rs index 1a2c2f6b9..c6a8b87a7 100644 --- a/crates/codegraph-core/src/extractors/kotlin.rs +++ b/crates/codegraph-core/src/extractors/kotlin.rs @@ -225,6 +225,7 @@ fn match_kotlin_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep complexity: None, cfg: None, children: None, + bodyless: None, }); } else if is_kotlin_enum(node) { let children = extract_kotlin_enum_entries(node, source); @@ -237,6 +238,7 @@ fn match_kotlin_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } else { let children = extract_kotlin_class_properties(node, source); @@ -249,6 +251,7 @@ fn match_kotlin_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } @@ -269,6 +272,7 @@ fn match_kotlin_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_kotlin_delegation_specifiers(node, source, &obj_name, symbols); } @@ -293,6 +297,7 @@ fn match_kotlin_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep complexity: compute_all_metrics(node, source, "kotlin"), cfg: build_function_cfg(node, "kotlin", source), children: opt_children(children), + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/lua.rs b/crates/codegraph-core/src/extractors/lua.rs index 5629d09e4..db52be604 100644 --- a/crates/codegraph-core/src/extractors/lua.rs +++ b/crates/codegraph-core/src/extractors/lua.rs @@ -5,6 +5,21 @@ use crate::types::*; use super::helpers::*; use super::SymbolExtractor; +/// Lua base-library global function names and standard-library module +/// tables. Mirrors `LUA_BUILTIN_GLOBALS` in `src/extractors/lua.ts` — see +/// `handle_lua_assignment_statement` (this file) and that file's +/// `handleLuaAssignmentStatement` for the full rationale (issue #1776). +const LUA_BUILTIN_GLOBALS: &[&str] = &[ + "assert", "collectgarbage", "dofile", "error", "getfenv", "getmetatable", + "ipairs", "load", "loadfile", "loadstring", "module", "next", "pairs", + "pcall", "print", "rawequal", "rawget", "rawlen", "rawset", "require", + "select", "setfenv", "setmetatable", "tonumber", "tostring", "type", + "unpack", "xpcall", + // Standard-library module tables — wholesale replacement (e.g. sandboxing) + // is the same "escapes local scope" shape as a single builtin function. + "string", "table", "math", "io", "os", "coroutine", "debug", "utf8", "bit32", +]; + pub struct LuaExtractor; impl SymbolExtractor for LuaExtractor { @@ -20,6 +35,7 @@ fn match_lua_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: match node.kind() { "function_declaration" => handle_lua_function_decl(node, source, symbols), "function_call" => handle_lua_function_call(node, source, symbols), + "assignment_statement" => handle_lua_assignment_statement(node, source, symbols), _ => {} } } @@ -67,6 +83,7 @@ fn handle_lua_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbol complexity: compute_all_metrics(node, source, "lua"), cfg: build_function_cfg(node, "lua", source), children: opt_children(params), + bodyless: None, }); } @@ -88,6 +105,52 @@ fn extract_lua_params(func_node: &Node, source: &[u8]) -> Vec { params } +/// Detect ` = ` assignments — a locally declared +/// function bound to a Lua global/builtin identifier (e.g. +/// `require = traced_require`), the monkey-patch pattern from issue #1776. +/// Mirrors `handleLuaAssignmentStatement` in `src/extractors/lua.ts` — see +/// that function's doc comment for the full rationale. +/// +/// Emits a dynamic `value-ref` call for the RHS identifier, restricted to +/// plain `identifier = identifier` pairs where the LHS matches +/// `LUA_BUILTIN_GLOBALS`. `value-ref` is resolved downstream +/// (build_edges.rs) against function/method-kind targets only, so a +/// builtin reassigned to a non-function value is silently dropped rather +/// than fabricating a nonsensical edge. +/// +/// Multi-assignment (`a, b = f, g`) is handled positionally: each side is +/// indexed independently by position (not pre-filtered to identifiers +/// first), so mixed variable kinds (`t.b, a = f, g`) do not shift the +/// pairing. +fn handle_lua_assignment_statement(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let Some(variable_list) = find_child(node, "variable_list") else { return }; + let Some(expression_list) = find_child(node, "expression_list") else { return }; + + let pair_count = variable_list + .named_child_count() + .min(expression_list.named_child_count()); + + for i in 0..pair_count { + let Some(lhs) = variable_list.named_child(i) else { continue }; + let Some(rhs) = expression_list.named_child(i) else { continue }; + if lhs.kind() != "identifier" || rhs.kind() != "identifier" { + continue; + } + let lhs_text = node_text(&lhs, source); + let rhs_text = node_text(&rhs, source); + if !LUA_BUILTIN_GLOBALS.contains(&lhs_text) || LUA_BUILTIN_GLOBALS.contains(&rhs_text) { + continue; + } + symbols.calls.push(Call { + name: rhs_text.to_string(), + line: start_line(&rhs), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); + } +} + fn handle_lua_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let name_node = match node.child_by_field_name("name") { Some(n) => n, @@ -199,3 +262,164 @@ fn handle_lua_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbol } } } + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn parse_lua(code: &str) -> FileSymbols { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_lua::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + LuaExtractor.extract(&tree, code.as_bytes(), "test.lua") + } + + // ── #1776: builtin/global reassignment value-ref extraction ───────────── + + #[test] + fn extracts_value_ref_call_for_function_assigned_to_builtin_global() { + let s = parse_lua( + "local function traced_require(modname)\n return modname\nend\nrequire = traced_require", + ); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "traced_require")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_call_for_local_shadow_of_builtin() { + let s = parse_lua( + "local function traced_require(modname)\n return modname\nend\nlocal require = traced_require", + ); + assert!(s + .calls + .iter() + .any(|c| c.name == "traced_require" && c.dynamic_kind.as_deref() == Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_lhs_is_not_a_recognized_builtin() { + let s = parse_lua("local function helper() end\nmyCustomGlobal = helper"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_itself_a_builtin() { + let s = parse_lua("print = tostring"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_local_non_builtin_alias() { + let s = parse_lua("local function helper() end\nlocal orig_helper = helper"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_a_call_expression() { + let s = parse_lua("require = wrapRequire(require)"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_a_member_expression() { + let s = parse_lua("require = mymodule.customRequire"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn pairs_multi_assignment_positionally() { + // `t.b` occupies position 0 (a dot_index_expression, not a plain + // identifier) — pairing must not shift, or `require` (position 1) + // would incorrectly pair with `helperA` (position 0). + let s = parse_lua( + "local function helperA() end\nlocal function helperB() end\nt.b, require = helperA, helperB", + ); + let value_refs: Vec<&str> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .map(|c| c.name.as_str()) + .collect(); + assert!(value_refs.contains(&"helperB")); + assert!(!value_refs.contains(&"helperA")); + } + + #[test] + fn extracts_value_ref_call_for_stdlib_module_table_reassignment() { + let s = parse_lua("local function fakeOs() end\nos = fakeOs"); + assert!(s + .calls + .iter() + .any(|c| c.name == "fakeOs" && c.dynamic_kind.as_deref() == Some("value-ref"))); + } + + // ── #1909: eval/computed-key dynamic-call detection ────────────────────── + + #[test] + fn classifies_load_call_as_dynamic_eval() { + let s = parse_lua("load(chunk)()"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("eval"))); + } + + #[test] + fn classifies_loadstring_call_as_dynamic_eval() { + let s = parse_lua("loadstring(code)()"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("eval"))); + } + + #[test] + fn classifies_dofile_call_as_dynamic_eval() { + let s = parse_lua("dofile(\"script.lua\")"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("eval"))); + } + + #[test] + fn resolves_bracket_index_call_with_string_literal_key_directly() { + let s = parse_lua("t[\"handler\"]()"); + assert!(s + .calls + .iter() + .any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t") && c.dynamic.is_none())); + } + + #[test] + fn resolves_bracket_index_call_with_single_quoted_string_literal_key_directly() { + let s = parse_lua("t['handler']()"); + assert!(s + .calls + .iter() + .any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t"))); + } + + #[test] + fn classifies_bracket_index_call_with_variable_key_as_computed_key() { + let s = parse_lua("t[k]()"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("computed-key") + && c.key_expr.as_deref() == Some("k") + && c.receiver.as_deref() == Some("t"))); + } + + #[test] + fn classifies_bracket_index_call_with_expression_key_as_computed_key() { + let s = parse_lua("handlers[eventName .. \"Handler\"]()"); + assert!(s.calls.iter().any(|c| c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("computed-key") + && c.receiver.as_deref() == Some("handlers"))); + } +} diff --git a/crates/codegraph-core/src/extractors/objc.rs b/crates/codegraph-core/src/extractors/objc.rs index 0bf3daa2e..389b8a1d8 100644 --- a/crates/codegraph-core/src/extractors/objc.rs +++ b/crates/codegraph-core/src/extractors/objc.rs @@ -72,6 +72,7 @@ fn handle_class_interface(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: opt_children(members), + bodyless: None, }); // Superclass — use the bare class name (categories already recorded above) @@ -133,6 +134,7 @@ fn handle_class_implementation(node: &Node, source: &[u8], symbols: &mut FileSym complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -151,6 +153,7 @@ fn handle_protocol_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -177,6 +180,7 @@ fn handle_method(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "objc"), cfg: build_function_cfg(node, "objc", source), children: opt_children(params), + bodyless: None, }); } @@ -195,6 +199,7 @@ fn handle_function_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "objc"), cfg: build_function_cfg(node, "objc", source), children: opt_children(params), + bodyless: None, }); } @@ -243,6 +248,7 @@ fn handle_struct_specifier(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -258,6 +264,7 @@ fn handle_enum_specifier(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -285,6 +292,7 @@ fn handle_typedef(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/ocaml.rs b/crates/codegraph-core/src/extractors/ocaml.rs index 38935793b..e3762b4af 100644 --- a/crates/codegraph-core/src/extractors/ocaml.rs +++ b/crates/codegraph-core/src/extractors/ocaml.rs @@ -67,6 +67,7 @@ fn handle_ocaml_let_binding(node: &Node, source: &[u8], symbols: &mut FileSymbol complexity: compute_all_metrics(node, source, "ocaml"), cfg: build_function_cfg(node, "ocaml", source), children: None, + bodyless: None, }); } else { symbols.definitions.push(Definition { @@ -78,6 +79,7 @@ fn handle_ocaml_let_binding(node: &Node, source: &[u8], symbols: &mut FileSymbol complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -124,6 +126,7 @@ fn handle_ocaml_module_def(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -151,6 +154,7 @@ fn handle_ocaml_type_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -193,6 +197,7 @@ fn handle_ocaml_class_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -235,6 +240,7 @@ fn handle_ocaml_value_spec(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -253,6 +259,7 @@ fn handle_ocaml_external(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -270,6 +277,7 @@ fn handle_ocaml_module_type_def(node: &Node, source: &[u8], symbols: &mut FileSy complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -294,6 +302,7 @@ fn handle_ocaml_exception_def(node: &Node, source: &[u8], symbols: &mut FileSymb complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/php.rs b/crates/codegraph-core/src/extractors/php.rs index 1feb71818..f2dd60d6c 100644 --- a/crates/codegraph-core/src/extractors/php.rs +++ b/crates/codegraph-core/src/extractors/php.rs @@ -55,6 +55,7 @@ fn handle_function_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "php"), cfg: build_function_cfg(node, "php", source), children: opt_children(children), + bodyless: None, }); } } @@ -72,6 +73,7 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); // Extends @@ -124,6 +126,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -139,6 +142,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: compute_all_metrics(&child, source, "php"), cfg: build_function_cfg(&child, "php", source), children: None, + bodyless: Some(child.child_by_field_name("body").is_none()), }); } } @@ -156,6 +160,7 @@ fn handle_trait_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -173,6 +178,7 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -195,6 +201,7 @@ fn handle_method_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "php"), cfg: build_function_cfg(node, "php", source), children: opt_children(children), + bodyless: Some(node.child_by_field_name("body").is_none()), }); } } diff --git a/crates/codegraph-core/src/extractors/python.rs b/crates/codegraph-core/src/extractors/python.rs index b0f46cee8..6d8ec4dbe 100644 --- a/crates/codegraph-core/src/extractors/python.rs +++ b/crates/codegraph-core/src/extractors/python.rs @@ -56,6 +56,7 @@ fn handle_function_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "python"), cfg: build_function_cfg(node, "python", source), children: opt_children(children), + bodyless: None, }); } @@ -72,6 +73,7 @@ fn handle_class_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); let superclasses = node .child_by_field_name("superclasses") @@ -109,6 +111,7 @@ fn handle_expr_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/r_lang.rs b/crates/codegraph-core/src/extractors/r_lang.rs index 4a7081013..896eef281 100644 --- a/crates/codegraph-core/src/extractors/r_lang.rs +++ b/crates/codegraph-core/src/extractors/r_lang.rs @@ -70,6 +70,7 @@ fn handle_binary_op(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(&rhs, source, "r"), cfg: build_function_cfg(&rhs, "r", source), children: opt_children(params), + bodyless: None, }); } else if is_program_level(node) { // Only record top-level variable assignments (matches JS extractor). @@ -82,6 +83,7 @@ fn handle_binary_op(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -299,6 +301,7 @@ fn handle_set_class(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -314,6 +317,7 @@ fn handle_set_generic(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/ruby.rs b/crates/codegraph-core/src/extractors/ruby.rs index 61bac796b..89a58f4d8 100644 --- a/crates/codegraph-core/src/extractors/ruby.rs +++ b/crates/codegraph-core/src/extractors/ruby.rs @@ -55,6 +55,7 @@ fn handle_assignment(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -73,6 +74,7 @@ fn handle_class(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); if let Some(superclass) = node.child_by_field_name("superclass") { extract_ruby_superclass(&superclass, &class_name, node, source, symbols); @@ -90,6 +92,7 @@ fn handle_module(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -112,6 +115,7 @@ fn handle_method(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "ruby"), cfg: build_function_cfg(node, "ruby", source), children: opt_children(children), + bodyless: None, }); } @@ -133,6 +137,7 @@ fn handle_singleton_method(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: compute_all_metrics(node, source, "ruby"), cfg: build_function_cfg(node, "ruby", source), children: opt_children(children), + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/rust_lang.rs b/crates/codegraph-core/src/extractors/rust_lang.rs index 8fc645eb4..7a761e046 100644 --- a/crates/codegraph-core/src/extractors/rust_lang.rs +++ b/crates/codegraph-core/src/extractors/rust_lang.rs @@ -13,7 +13,12 @@ impl SymbolExtractor for RustExtractor { walk_tree(&tree.root_node(), source, &mut symbols, match_rust_node); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &RUST_AST_CONFIG); walk_tree(&tree.root_node(), source, &mut symbols, match_rust_type_map); + walk_tree(&tree.root_node(), source, &mut symbols, match_rust_return_type_map); + // Must run after type_map is populated — resolves `receiver.method()` call + // assignments against locally-typed receivers (mirrors javascript.rs's ordering). + walk_tree(&tree.root_node(), source, &mut symbols, match_rust_call_assignments); dedup_type_map(&mut symbols.type_map); + dedup_type_map(&mut symbols.return_type_map); symbols } } @@ -72,14 +77,16 @@ fn handle_function_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "rust"), cfg: build_function_cfg(node, "rust", source), children: opt_children(children), + bodyless: None, }); } fn handle_struct_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(name_node) = node.child_by_field_name("name") { + let struct_name = node_text(&name_node, source).to_string(); let children = extract_rust_struct_fields(node, source); symbols.definitions.push(Definition { - name: node_text(&name_node, source).to_string(), + name: struct_name.clone(), kind: "struct".to_string(), line: start_line(node), end_line: Some(end_line(node)), @@ -87,7 +94,29 @@ fn handle_struct_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); + seed_rust_struct_field_types(node, &struct_name, source, symbols); + } +} + +/// Seed `${StructName}.${fieldName}` → field-type entries in `symbols.type_map` +/// so `self.field.method()` inside the struct's own impl methods resolves via +/// the class-scoped receiver lookup — mirrors JS's `this.field` class-scoped +/// typing (issues #1323, #1458) and fixes #1876's `self.field` false negatives. +fn seed_rust_struct_field_types(node: &Node, struct_name: &str, source: &[u8], symbols: &mut FileSymbols) { + let Some(body) = node.child_by_field_name("body") else { return }; + for i in 0..body.child_count() { + let Some(field) = body.child(i) else { continue }; + if field.kind() != "field_declaration" { continue } + let Some(field_name) = field.child_by_field_name("name") else { continue }; + let Some(type_node) = field.child_by_field_name("type") else { continue }; + let Some(type_name) = extract_rust_type_name(&type_node, source) else { continue }; + push_type_map_entry( + symbols, + format!("{}.{}", struct_name, node_text(&field_name, source)), + type_name, + ); } } @@ -103,6 +132,7 @@ fn handle_enum_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -118,6 +148,7 @@ fn handle_const_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } } @@ -134,6 +165,7 @@ fn handle_trait_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); if let Some(body) = node.child_by_field_name("body") { for i in 0..body.child_count() { @@ -151,6 +183,7 @@ fn handle_trait_item(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(&child, source, "rust"), cfg: build_function_cfg(&child, "rust", source), children: None, + bodyless: Some(child.kind() == "function_signature_item"), }); } } @@ -401,6 +434,20 @@ fn match_rust_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _d confidence: 0.9, }); } + } else if let Some(value_node) = node.child_by_field_name("value") { + // let x = TypeName; — a bare capitalized identifier value binds + // a unit-struct instance (e.g. `let v = NameValidator;` for + // `struct NameValidator;`), not a reference to another variable (#1876). + if value_node.kind() == "identifier" { + let type_name = node_text(&value_node, source); + if type_name.starts_with(|c: char| c.is_uppercase()) { + symbols.type_map.push(TypeMapEntry { + name: node_text(&pattern, source).to_string(), + type_name: type_name.to_string(), + confidence: 0.7, + }); + } + } } } } @@ -427,6 +474,105 @@ fn match_rust_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _d } } +// ── Return-type map extraction (Phase 8.2 parity, #1876) ──────────────────── + +/// Populate `symbols.return_type_map` with declared `-> ReturnType` return +/// types for free functions and impl methods, resolving `Self` to the +/// enclosing impl's type name. Mirrors `extractRustReturnTypeMap` in +/// `src/extractors/rust.ts`. Consumed by `propagate_return_types_across_files` +/// (Phase 8.2) — the same generic cross-file mechanism the JS/TS extractor +/// feeds — so a local var typed from a cross-file call's return value +/// (`let service = build_service();`) resolves without any Rust-specific +/// propagation logic. +fn match_rust_return_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + if node.kind() != "function_item" { return } + // Skip default-impl functions inside traits, matching handle_function_item — + // their return type is not tied to a concrete implementing type. + if node.parent().and_then(|p| p.parent()).map_or(false, |gp| gp.kind() == "trait_item") { + return; + } + let Some(name_node) = node.child_by_field_name("name") else { return }; + let Some(return_type_node) = node.child_by_field_name("return_type") else { return }; + let Some(raw_type) = extract_rust_type_name(&return_type_node, source) else { return }; + let impl_type = find_current_impl(node, source); + // `-> Self` inside an impl block returns the concrete implementing type. + let type_name = if raw_type == "Self" { + impl_type.as_deref().unwrap_or(raw_type) + } else { + raw_type + }; + let full_name = match &impl_type { + Some(t) => format!("{}.{}", t, node_text(&name_node, source)), + None => node_text(&name_node, source).to_string(), + }; + let existing_confidence = symbols.return_type_map.iter() + .find(|e| e.name == full_name) + .map(|e| e.confidence); + if existing_confidence.map_or(true, |c| c < 1.0) { + symbols.return_type_map.push(TypeMapEntry { + name: full_name, + type_name: type_name.to_string(), + confidence: 1.0, + }); + } +} + +// ── Call-assignment extraction (Phase 8.2 parity, #1876) ───────────────────── + +/// Record `let x = callee(...);` bindings into `symbols.call_assignments` so +/// `propagate_return_types_across_files` can type `x` from `callee`'s +/// declared return type. Mirrors `extractRustCallAssignments` in +/// `src/extractors/rust.ts` — see that function's doc comment for the three +/// call shapes handled (bare function call, `Type::assoc_fn()`, and method +/// call on a locally-typed receiver). +fn match_rust_call_assignments(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + if node.kind() != "let_declaration" { return } + let Some(pattern) = node.child_by_field_name("pattern") else { return }; + if pattern.kind() != "identifier" { return } + let Some(value) = node.child_by_field_name("value") else { return }; + if value.kind() != "call_expression" { return } + let Some(fn_node) = value.child_by_field_name("function") else { return }; + let var_name = node_text(&pattern, source).to_string(); + + match fn_node.kind() { + "identifier" => { + symbols.call_assignments.push(NativeCallAssignment { + var_name, + callee_name: node_text(&fn_node, source).to_string(), + receiver_type_name: None, + }); + } + "scoped_identifier" => { + let name = fn_node.child_by_field_name("name"); + let path = fn_node.child_by_field_name("path"); + if let (Some(name), Some(path)) = (name, path) { + symbols.call_assignments.push(NativeCallAssignment { + var_name, + callee_name: node_text(&name, source).to_string(), + receiver_type_name: Some(node_text(&path, source).to_string()), + }); + } + } + "field_expression" => { + let field = fn_node.child_by_field_name("field"); + let receiver = fn_node.child_by_field_name("value"); + if let (Some(field), Some(receiver)) = (field, receiver) { + if receiver.kind() == "identifier" { + let receiver_type = symbols.type_map.iter() + .find(|e| e.name == node_text(&receiver, source)) + .map(|e| e.type_name.clone()); + symbols.call_assignments.push(NativeCallAssignment { + var_name, + callee_name: node_text(&field, source).to_string(), + receiver_type_name: receiver_type, + }); + } + } + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -454,6 +600,27 @@ mod tests { assert_eq!(children[1].name, "b"); } + /// Regression test for #1922: a trait's required method (`function_signature_item`, + /// no default implementation) has no body and must be marked `bodyless`; a + /// default-implemented trait method (`function_item`, has a body) must not be. + #[test] + fn trait_required_method_is_bodyless_default_method_is_not() { + let s = parse_rust( + "trait Repo {\n\ + fn save(&mut self, id: &str, value: i32) -> bool;\n\ + fn find(&self, id: &str) -> Option {\n\ + if id.is_empty() { return None; }\n\ + None\n\ + }\n\ + }\n", + ); + let save = s.definitions.iter().find(|d| d.name == "Repo.save").unwrap(); + assert_eq!(save.bodyless, Some(true)); + + let find = s.definitions.iter().find(|d| d.name == "Repo.find").unwrap(); + assert_ne!(find.bodyless, Some(true)); + } + #[test] fn extracts_struct_fields() { let s = parse_rust("struct User { name: String, age: u32 }"); @@ -492,4 +659,67 @@ mod tests { assert_eq!(children.len(), 1); assert_eq!(children[0].name, "x"); } + + // ── #1876: receiver-typed locals + self.field type map ────────────────── + + #[test] + fn seeds_struct_field_type_map() { + let s = parse_rust("struct UserService { repo: UserRepository }"); + let entry = s.type_map.iter().find(|e| e.name == "UserService.repo").unwrap(); + assert_eq!(entry.type_name, "UserRepository"); + } + + #[test] + fn seeds_unit_struct_value_type_map() { + let s = parse_rust("struct NameValidator;\nfn f() { let v = NameValidator; }"); + let entry = s.type_map.iter().find(|e| e.name == "v").unwrap(); + assert_eq!(entry.type_name, "NameValidator"); + } + + #[test] + fn does_not_type_lowercase_bare_identifier_binding() { + let s = parse_rust("fn f() { let a = 1; let b = a; }"); + assert!(s.type_map.iter().all(|e| e.name != "b")); + } + + #[test] + fn stores_return_type_for_free_function() { + let s = parse_rust("fn build_service() -> UserService { todo!() }"); + let entry = s.return_type_map.iter().find(|e| e.name == "build_service").unwrap(); + assert_eq!(entry.type_name, "UserService"); + assert_eq!(entry.confidence, 1.0); + } + + #[test] + fn resolves_self_return_type_to_impl_type() { + let s = parse_rust("struct UserRepository;\nimpl UserRepository {\n fn new() -> Self { UserRepository }\n}"); + let entry = s.return_type_map.iter().find(|e| e.name == "UserRepository.new").unwrap(); + assert_eq!(entry.type_name, "UserRepository"); + } + + #[test] + fn records_call_assignment_for_bare_function_call() { + let s = parse_rust("fn f() { let service = build_service(); }"); + let ca = s.call_assignments.iter().find(|c| c.var_name == "service").unwrap(); + assert_eq!(ca.callee_name, "build_service"); + assert_eq!(ca.receiver_type_name, None); + } + + #[test] + fn records_call_assignment_for_associated_function_call() { + let s = parse_rust("fn f() { let repo = UserRepository::new(); }"); + let ca = s.call_assignments.iter().find(|c| c.var_name == "repo").unwrap(); + assert_eq!(ca.callee_name, "new"); + assert_eq!(ca.receiver_type_name.as_deref(), Some("UserRepository")); + } + + #[test] + fn records_call_assignment_for_method_call_on_typed_receiver() { + let s = parse_rust( + "fn f() {\n let repo: UserRepository = make();\n let user = repo.find_by_id(1);\n}", + ); + let ca = s.call_assignments.iter().find(|c| c.var_name == "user").unwrap(); + assert_eq!(ca.callee_name, "find_by_id"); + assert_eq!(ca.receiver_type_name.as_deref(), Some("UserRepository")); + } } diff --git a/crates/codegraph-core/src/extractors/scala.rs b/crates/codegraph-core/src/extractors/scala.rs index 60c4fa34a..f06b10884 100644 --- a/crates/codegraph-core/src/extractors/scala.rs +++ b/crates/codegraph-core/src/extractors/scala.rs @@ -213,6 +213,7 @@ fn handle_scala_class_definition(node: &Node, source: &[u8], symbols: &mut FileS complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_scala_extends(node, source, &class_name, symbols); } @@ -232,6 +233,7 @@ fn handle_scala_trait_definition(node: &Node, source: &[u8], symbols: &mut FileS complexity: None, cfg: None, children: None, + bodyless: None, }); extract_scala_extends(node, source, &trait_name, symbols); } @@ -252,6 +254,7 @@ fn handle_scala_object_definition(node: &Node, source: &[u8], symbols: &mut File complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); extract_scala_extends(node, source, &obj_name, symbols); } @@ -278,6 +281,7 @@ fn handle_scala_function_definition(node: &Node, source: &[u8], symbols: &mut Fi complexity: compute_all_metrics(node, source, "scala"), cfg: build_function_cfg(node, "scala", source), children: opt_children(children), + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/solidity.rs b/crates/codegraph-core/src/extractors/solidity.rs index 6aa6e9694..d32de44c4 100644 --- a/crates/codegraph-core/src/extractors/solidity.rs +++ b/crates/codegraph-core/src/extractors/solidity.rs @@ -79,6 +79,7 @@ fn handle_contract_decl( complexity: None, cfg: None, children: opt_children(members), + bodyless: None, }); extract_inheritance(node, &name, source, symbols); @@ -128,6 +129,7 @@ fn extract_contract_member(child: &Node, source: &[u8]) -> Option { complexity: None, cfg: None, children: None, + bodyless: None, }) } "error_declaration" => { @@ -141,6 +143,7 @@ fn extract_contract_member(child: &Node, source: &[u8]) -> Option { complexity: None, cfg: None, children: None, + bodyless: None, }) } "modifier_definition" => { @@ -154,6 +157,7 @@ fn extract_contract_member(child: &Node, source: &[u8]) -> Option { complexity: None, cfg: None, children: None, + bodyless: None, }) } _ => None, @@ -231,6 +235,7 @@ fn handle_struct_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(members), + bodyless: None, }); } @@ -270,6 +275,7 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(members), + bodyless: None, }); } @@ -296,6 +302,7 @@ fn handle_function_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(params), + bodyless: None, }); } @@ -318,6 +325,7 @@ fn handle_modifier_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -340,6 +348,7 @@ fn handle_event_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -362,6 +371,7 @@ fn handle_error_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -384,6 +394,7 @@ fn handle_state_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/swift.rs b/crates/codegraph-core/src/extractors/swift.rs index 51a57d0a0..25b9bb20e 100644 --- a/crates/codegraph-core/src/extractors/swift.rs +++ b/crates/codegraph-core/src/extractors/swift.rs @@ -243,6 +243,7 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } _ => { @@ -256,6 +257,7 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept complexity: None, cfg: None, children: opt_children(children), + bodyless: None, }); } } @@ -278,6 +280,7 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept complexity: None, cfg: None, children: None, + bodyless: None, }); // Protocol can also have inheritance extract_swift_inheritance(node, source, &proto_name, symbols); @@ -305,6 +308,7 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept complexity: compute_all_metrics(node, source, "swift"), cfg: build_function_cfg(node, "swift", source), children: opt_children(children), + bodyless: None, }); } } diff --git a/crates/codegraph-core/src/extractors/verilog.rs b/crates/codegraph-core/src/extractors/verilog.rs index 8d8e8204f..b50765c7f 100644 --- a/crates/codegraph-core/src/extractors/verilog.rs +++ b/crates/codegraph-core/src/extractors/verilog.rs @@ -69,6 +69,7 @@ fn handle_module_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: opt_children(ports), + bodyless: None, }); } @@ -86,6 +87,7 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -103,6 +105,7 @@ fn handle_package_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -128,6 +131,7 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); if let Some(superclass) = find_class_superclass(node, source) { @@ -193,6 +197,7 @@ fn handle_function_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -215,6 +220,7 @@ fn handle_task_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/extractors/zig.rs b/crates/codegraph-core/src/extractors/zig.rs index ed412c914..6d491b5b0 100644 --- a/crates/codegraph-core/src/extractors/zig.rs +++ b/crates/codegraph-core/src/extractors/zig.rs @@ -50,6 +50,7 @@ fn handle_zig_function(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: compute_all_metrics(node, source, "zig"), cfg: build_function_cfg(node, "zig", source), children: opt_children(params), + bodyless: None, }); } @@ -98,6 +99,7 @@ fn handle_zig_variable(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } @@ -119,6 +121,7 @@ fn try_handle_zig_type_def(node: &Node, source: &[u8], symbols: &mut FileSymbols complexity: None, cfg: None, children, + bodyless: None, }); return true; } @@ -259,6 +262,7 @@ fn handle_zig_test(node: &Node, source: &[u8], symbols: &mut FileSymbols) { complexity: None, cfg: None, children: None, + bodyless: None, }); } diff --git a/crates/codegraph-core/src/features/structure.rs b/crates/codegraph-core/src/features/structure.rs index 7b9897cb7..514b7fd34 100644 --- a/crates/codegraph-core/src/features/structure.rs +++ b/crates/codegraph-core/src/features/structure.rs @@ -9,7 +9,7 @@ use crate::types::FileSymbols; use rusqlite::Connection; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; /// Per-file metrics to upsert into node_metrics. #[derive(Debug, Clone)] @@ -25,7 +25,7 @@ pub struct FileMetrics { /// Build line count map from parsed file symbols. pub fn build_line_count_map( - file_symbols: &HashMap, + file_symbols: &BTreeMap, root_dir: &str, ) -> HashMap { let mut map = HashMap::new(); @@ -50,7 +50,7 @@ pub fn update_changed_file_metrics( conn: &Connection, changed_files: &[String], line_count_map: &HashMap, - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) { if changed_files.is_empty() { return; @@ -141,6 +141,247 @@ pub fn get_existing_file_count(conn: &Connection) -> i64 { .unwrap_or(0) } +/// Directories connected to `dir` via a live import/imports-type edge in +/// either direction — the cross-directory neighbours whose own fan-in/out +/// may have shifted even though none of their files changed. Used by +/// `refresh_affected_directory_metrics` to expand its affected-directory set +/// by exactly one hop. +fn find_neighbor_files(conn: &Connection, dir: &str) -> Vec { + let lo = format!("{dir}/"); + let hi = format!("{dir}0"); + let mut stmt = match conn.prepare( + "SELECT n2.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \ + AND n1.file >= ?1 AND n1.file < ?2 \ + UNION \ + SELECT n1.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \ + AND n2.file >= ?1 AND n2.file < ?2", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let result = match stmt.query_map(rusqlite::params![lo, hi], |row| row.get::<_, String>(0)) { + Ok(rows) => rows.flatten().collect(), + Err(_) => Vec::new(), + }; + result +} + +/// Targeted directory-metrics refresh for the small-incremental fast path. +/// +/// `update_changed_file_metrics` only ever touches per-file `node_metrics` +/// rows — it never looks at directories. Any file added to, removed from, or +/// edited within a directory left that directory's +/// fileCount/symbolCount/fanIn/fanOut/cohesion stale until the next full +/// rebuild (#1738), and a file added under a brand-new directory never even +/// got a directory node or a `contains` edge from its parent. +/// +/// This recomputes metrics for the ancestor directories of the files that +/// changed in this build (added, removed, or modified), PLUS any directory +/// reachable from them via a live cross-directory import edge — a changed +/// file that gains (or loses) an import into a sibling package shifts that +/// package's fan-in/fan-out/cohesion even though none of its own files were +/// touched. One level of expansion only (mirrors the neighbour-expansion +/// `classifyNodeRolesIncremental`/`do_classify_incremental` already does for +/// role classification) — bounded by (changed files × path depth) rather +/// than the size of the repo, so it stays cheap enough to run +/// unconditionally alongside the fast path. +/// +/// Removed files need no edge/node cleanup of their own — the purge step +/// already deleted their nodes and every edge referencing them (including +/// their old `contains` edge) earlier in the pipeline; only their ancestor +/// directories' aggregates need recomputing here. A removed file's own +/// cross-directory neighbors (files it imported, or that imported it) can no +/// longer be discovered from LIVE edges by the time this runs — those edges +/// are already purged — so the pipeline captures them up front, before the +/// purge, via `detect_changes::capture_removed_file_neighbors` and passes +/// them in as `removed_file_neighbors` (#1839). +pub fn refresh_affected_directory_metrics( + conn: &Connection, + changed_files: &[String], + removed_files: &[String], + removed_file_neighbors: &[String], +) { + let mut touched: Vec = Vec::with_capacity( + changed_files.len() + removed_files.len() + removed_file_neighbors.len(), + ); + touched.extend_from_slice(changed_files); + touched.extend_from_slice(removed_files); + touched.extend_from_slice(removed_file_neighbors); + let mut affected_dirs = get_ancestor_dirs(&touched); + if affected_dirs.is_empty() { + return; + } + + let seed_dirs: Vec = affected_dirs.iter().cloned().collect(); + for dir in &seed_dirs { + let neighbor_files = find_neighbor_files(conn, dir); + for ancestor in get_ancestor_dirs(&neighbor_files) { + affected_dirs.insert(ancestor); + } + } + + let tx = match conn.unchecked_transaction() { + Ok(tx) => tx, + Err(_) => return, + }; + + // 1. Ensure directory nodes exist for the whole affected ancestor chain — + // handles a file added under a brand-new (possibly multi-level) directory. + { + let mut insert_dir = match tx.prepare( + "INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, 'directory', ?, 0, NULL)", + ) { + Ok(s) => s, + Err(_) => return, + }; + for dir in &affected_dirs { + let _ = insert_dir.execute(rusqlite::params![dir, dir]); + } + } + + { + let mut insert_edge = match tx.prepare( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) \ + SELECT ?, ?, 'contains', 1.0, 0 \ + WHERE NOT EXISTS (SELECT 1 FROM edges WHERE source_id = ? AND target_id = ? AND kind = 'contains')", + ) { + Ok(s) => s, + Err(_) => return, + }; + + // 2. Wire dir -> parent-dir contains edges for the chain. + for dir in &affected_dirs { + if let Some(parent) = parent_dir(dir) { + if let (Some(parent_id), Some(dir_id)) = ( + get_node_id(&tx, &parent, "directory", &parent, 0), + get_node_id(&tx, dir, "directory", dir, 0), + ) { + let _ = + insert_edge.execute(rusqlite::params![parent_id, dir_id, parent_id, dir_id]); + } + } + } + + // 3. Wire dir -> file contains edges for changed (added/modified) files. + // Removed files' nodes and edges are already purged upstream. + for rel_path in changed_files { + if let Some(dir) = parent_dir(rel_path) { + if let (Some(dir_id), Some(file_id)) = ( + get_node_id(&tx, &dir, "directory", &dir, 0), + get_node_id(&tx, rel_path, "file", rel_path, 0), + ) { + let _ = + insert_edge.execute(rusqlite::params![dir_id, file_id, dir_id, file_id]); + } + } + } + } + + // 4. Recompute each affected directory's metrics from the live DB state. + { + // fileCount/symbolCount: transitive counts under `dir`, matching + // compute_directory_metrics below. `file >= dir/ AND file < dir0` is + // an index-friendly prefix-range scan equivalent to `file LIKE + // 'dir/%'` — '0' (0x30) is the character immediately after '/' + // (0x2F), so this bound matches exactly the paths nested under `dir`. + let mut count_files = match tx.prepare( + "SELECT COUNT(*) FROM nodes WHERE kind = 'file' AND file >= ?1 AND file < ?2", + ) { + Ok(s) => s, + Err(_) => return, + }; + let mut count_symbols = match tx.prepare( + "SELECT COUNT(*) FROM nodes WHERE kind != 'file' AND kind != 'directory' AND file >= ?1 AND file < ?2", + ) { + Ok(s) => s, + Err(_) => return, + }; + // Edges sourced from a file inside dir: intra (target also inside dir) vs fan-out. + let mut outbound = match tx.prepare( + "SELECT \ + COALESCE(SUM(CASE WHEN n2.file >= ?1 AND n2.file < ?2 THEN 1 ELSE 0 END), 0), \ + COUNT(*) \ + FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id \ + JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') \ + AND n1.file != n2.file \ + AND n2.kind = 'file' \ + AND n1.file >= ?1 AND n1.file < ?2", + ) { + Ok(s) => s, + Err(_) => return, + }; + // Edges targeting a file inside dir, sourced from a file outside dir (fan-in only). + let mut inbound = match tx.prepare( + "SELECT COUNT(*) \ + FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id \ + JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') \ + AND n1.file != n2.file \ + AND n2.kind = 'file' \ + AND n2.file >= ?1 AND n2.file < ?2 \ + AND NOT (n1.file >= ?1 AND n1.file < ?2)", + ) { + Ok(s) => s, + Err(_) => return, + }; + let mut upsert = match tx.prepare( + "INSERT OR REPLACE INTO node_metrics \ + (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count) \ + VALUES (?, NULL, ?, NULL, NULL, ?, ?, ?, ?)", + ) { + Ok(s) => s, + Err(_) => return, + }; + + for dir in &affected_dirs { + let dir_id = match get_node_id(&tx, dir, "directory", dir, 0) { + Some(id) => id, + None => continue, + }; + let lo = format!("{dir}/"); + let hi = format!("{dir}0"); + + let file_count: i64 = count_files + .query_row(rusqlite::params![lo, hi], |r| r.get(0)) + .unwrap_or(0); + let symbol_count: i64 = count_symbols + .query_row(rusqlite::params![lo, hi], |r| r.get(0)) + .unwrap_or(0); + let (intra, total): (i64, i64) = outbound + .query_row(rusqlite::params![lo, hi], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap_or((0, 0)); + let fan_out = total - intra; + let fan_in: i64 = inbound + .query_row(rusqlite::params![lo, hi], |r| r.get(0)) + .unwrap_or(0); + let total_edges = intra + fan_in + fan_out; + let cohesion: Option = if total_edges > 0 { + Some(intra as f64 / total_edges as f64) + } else { + None + }; + + let _ = upsert.execute(rusqlite::params![ + dir_id, + symbol_count, + fan_in, + fan_out, + cohesion, + file_count + ]); + } + } + + let _ = tx.commit(); +} + // ── Full structure computation ────────────────────────────────────────── /// Normalize a path to use forward slashes only. @@ -250,7 +491,7 @@ struct ImportEdge { /// and contains-edge insertion to affected directories only. pub fn build_full_structure( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, discovered_dirs: &HashSet, root_dir: &str, line_count_map: &HashMap, @@ -408,7 +649,7 @@ fn load_file_paths_in_dirs(conn: &Connection, dirs: &HashSet) -> Vec, + file_symbols: &BTreeMap, all_file_paths: &[String], affected_dirs: Option<&HashSet>, ) { @@ -508,7 +749,7 @@ fn restore_unchanged_dir_edges( fn insert_contains_edges( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, all_dirs: &HashSet, changed_files: Option<&[String]>, ) { @@ -595,7 +836,7 @@ fn compute_import_edge_maps( fn compute_file_metrics( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, line_count_map: &HashMap, fan_in_map: &HashMap, fan_out_map: &HashMap, @@ -718,7 +959,7 @@ fn record_file_in_ancestor_dirs<'a>( fn build_dir_files_map<'a>( all_dirs: &'a HashSet, all_db_files: &'a [String], - file_symbols: &'a HashMap, + file_symbols: &'a BTreeMap, ) -> HashMap<&'a str, Vec<&'a str>> { let mut dir_files: HashMap<&str, Vec<&str>> = HashMap::new(); for dir in all_dirs { @@ -831,7 +1072,7 @@ fn count_distinct_definitions(sym: &FileSymbols) -> i64 { fn compute_dir_symbol_counts<'a>( dir_files: &HashMap<&'a str, Vec<&'a str>>, db_symbol_counts: &HashMap, - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> HashMap<&'a str, i64> { let mut dir_symbol_counts: HashMap<&str, i64> = HashMap::new(); for (dir, files) in dir_files { @@ -897,7 +1138,7 @@ fn write_directory_metric_rows( fn compute_directory_metrics( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, all_dirs: &HashSet, import_edges: &[ImportEdge], ) { @@ -920,7 +1161,7 @@ mod tests { #[test] fn line_count_map_from_symbols() { - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); let mut sym = FileSymbols::new("src/a.ts".to_string()); sym.line_count = Some(42); file_symbols.insert("src/a.ts".to_string(), sym.clone()); diff --git a/crates/codegraph-core/src/graph/algorithms/leiden.rs b/crates/codegraph-core/src/graph/algorithms/leiden.rs new file mode 100644 index 000000000..823db557a --- /dev/null +++ b/crates/codegraph-core/src/graph/algorithms/leiden.rs @@ -0,0 +1,1410 @@ +//! Leiden community detection (undirected modularity), ported from the +//! TypeScript reference implementation in `src/graph/algorithms/leiden/*` +//! (vendored from ngraph.leiden, MIT — see that directory's LICENSE) to fix +//! issue #1804: the native (this file, formerly classic Louvain) and JS +//! fallback (`detectClusters` in the TS `leiden/` directory) engines used to +//! run two genuinely different community-detection algorithms, so +//! `codegraph communities`/`--drift` reported different partitions purely +//! based on whether the native addon loaded. Both engines must now run +//! Leiden. +//! +//! ## Scope +//! +//! This port covers exactly the option surface reachable through +//! `louvainCommunities`/`LouvainOptions` +//! (`src/graph/algorithms/louvain.ts`): undirected graphs, modularity +//! quality (not CPM), the default "neighbors" candidate strategy, +//! `refine: true` (always), uniform node size (1.0) and edge weight (1.0 +//! per edge — `GraphEdge` carries no weight field), no +//! `maxCommunitySize`/`fixedNodes`/`preserveLabels` overrides. These are the +//! *only* knobs `louvainCommunities` ever threads through to either engine +//! (see `LouvainOptions` and `louvainJS()`'s call into `detectClusters`). +//! +//! The TS `leiden/` directory's directed-graph mode, CPM quality function, +//! alternate candidate strategies (all/random/random-neighbor), +//! `allowNewCommunity`, `fixedNodes`, and `preserveLabels` knobs are **not** +//! ported — they are unreachable from this binding today. Issue #1936 +//! tracks porting that remaining surface if a caller ever needs to drive +//! native Leiden with it. +//! +//! ## Determinism +//! +//! Every place the TS reference relies on `Map`/`Array` *insertion order* +//! (not sorted order) to break ties deterministically, this port uses an +//! explicit insertion-order-preserving structure (a `Vec` of records plus a +//! `HashMap` used purely for O(1) index lookup, never iterated) rather than +//! a `BTreeMap`. A `BTreeMap` would still be deterministic across runs, but +//! it iterates in *sorted* order, which does not match the TS reference's +//! *insertion* order — silently reordering a node's adjacency list relative +//! to the JS engine and changing which candidate community wins a tie in +//! the local-move/refinement phases. That would reintroduce a cross-engine +//! divergence of exactly the kind this file exists to fix, so `HashMap` is +//! used strictly as a lookup index here, never as an iterated collection. +//! +//! Separately (and orthogonally), every `HashMap` used as a plain lookup +//! (e.g. `id_to_idx`) is safe from issue #1734's failure mode (Rust's +//! per-process-randomized hasher reordering iteration) because it is never +//! iterated — only `.get()`/`.insert()` are used. + +use std::collections::HashMap; + +use napi_derive::napi; + +use crate::shared::constants::{ + DEFAULT_RANDOM_SEED, LEIDEN_DEFAULT_CAPACITY_GROWTH_FACTOR, LEIDEN_DEFAULT_MAX_LEVELS, + LEIDEN_DEFAULT_MAX_LOCAL_PASSES, LEIDEN_DEFAULT_REFINEMENT_THETA, LEIDEN_DEFAULT_RESOLUTION, + LEIDEN_GAIN_EPSILON, +}; +use crate::types::GraphEdge; + +// ════════════════════════════════════════════════════════════════════════ +// napi-facing types + entry point +// ════════════════════════════════════════════════════════════════════════ + +#[napi(object)] +#[derive(Debug, Clone)] +pub struct LeidenCommunityAssignment { + pub node: String, + pub community: i32, +} + +#[napi(object)] +#[derive(Debug, Clone)] +pub struct LeidenCommunitiesResult { + pub assignments: Vec, + pub modularity: f64, +} + +/// Leiden community detection (undirected modularity optimization). +/// +/// Mirrors `detectClusters(graph, { resolution, randomSeed, directed: false, +/// maxLevels, maxLocalPasses, refinementTheta, capacityGrowthFactor })` in +/// `src/graph/algorithms/leiden/index.ts` exactly — see the module doc for +/// the precise (and deliberately narrower) option surface covered. +#[napi] +#[allow(clippy::too_many_arguments)] +pub fn leiden_communities( + edges: Vec, + node_ids: Vec, + resolution: Option, + random_seed: Option, + max_levels: Option, + max_local_passes: Option, + refinement_theta: Option, + capacity_growth_factor: Option, +) -> LeidenCommunitiesResult { + if edges.is_empty() || node_ids.is_empty() { + return LeidenCommunitiesResult { + assignments: vec![], + modularity: 0.0, + }; + } + + let cfg = LeidenConfig { + resolution: resolution.unwrap_or(LEIDEN_DEFAULT_RESOLUTION), + max_levels: (max_levels.unwrap_or(LEIDEN_DEFAULT_MAX_LEVELS as u32) as usize).max(1), + max_local_passes: (max_local_passes.unwrap_or(LEIDEN_DEFAULT_MAX_LOCAL_PASSES as u32) + as usize) + .max(1), + // TS throws a RangeError for theta <= 0 (optimiser.ts). This binding + // is never reached with a caller-controlled theta outside + // `.codegraphrc.json`'s `community.refinementTheta` (config default + // 1.0), so rather than panic across the FFI boundary for a + // misconfiguration, fall back to the default -- strictly safer than + // the old native path, which silently ignored this option entirely. + refinement_theta: refinement_theta + .filter(|&t| t > 0.0) + .unwrap_or(LEIDEN_DEFAULT_REFINEMENT_THETA), + capacity_growth_factor: capacity_growth_factor + .unwrap_or(LEIDEN_DEFAULT_CAPACITY_GROWTH_FACTOR), + }; + let seed = random_seed.unwrap_or(DEFAULT_RANDOM_SEED); + + let n = node_ids.len(); + let mut id_to_idx: HashMap<&str, usize> = HashMap::with_capacity(n); + for (i, id) in node_ids.iter().enumerate() { + id_to_idx.insert(id.as_str(), i); + } + // Edge weight is always 1.0: `GraphEdge` carries no weight field, and + // `graph.toEdgeArray()` (the sole caller, in louvain.ts) never attaches + // one either -- matching the TS reference's default `linkWeight` + // fallback, which is the only value ever exercised by this binding. + let raw_edges: Vec<(usize, usize, f64)> = edges + .iter() + .filter_map(|e| { + let a = *id_to_idx.get(e.source.as_str())?; + let b = *id_to_idx.get(e.target.as_str())?; + Some((a, b, 1.0)) + }) + .collect(); + + let base_graph = build_adapter(n, &raw_edges, &vec![1.0; n]); + + if base_graph.total_weight == 0.0 { + return LeidenCommunitiesResult { + assignments: node_ids + .iter() + .enumerate() + .map(|(i, id)| LeidenCommunityAssignment { + node: id.clone(), + community: i as i32, + }) + .collect(), + modularity: 0.0, + }; + } + + let original_to_current = run_leiden(&base_graph, &cfg, seed); + let modularity = compute_final_modularity(&base_graph, &original_to_current); + + let assignments = node_ids + .iter() + .enumerate() + .map(|(i, id)| LeidenCommunityAssignment { + node: id.clone(), + community: original_to_current[i] as i32, + }) + .collect(); + + LeidenCommunitiesResult { + assignments, + modularity, + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Configuration +// ════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Copy)] +struct LeidenConfig { + resolution: f64, + max_levels: usize, + max_local_passes: usize, + refinement_theta: f64, + capacity_growth_factor: f64, +} + +// ════════════════════════════════════════════════════════════════════════ +// RNG — mulberry32, bit-for-bit port of src/graph/algorithms/leiden/rng.ts +// ════════════════════════════════════════════════════════════════════════ + +/// Mulberry32 PRNG. All arithmetic is done on `u32` with wrapping ops, which +/// is bit-for-bit equivalent to the TS reference's `|0`/`>>>`/`Math.imul` +/// int32 bit-pattern arithmetic: JS's 32-bit bitwise/imul operations only +/// ever depend on the operands' 32-bit bit patterns, not their signed vs. +/// unsigned interpretation, so representing the state as `u32` throughout +/// (rather than mirroring JS's "signed int32" framing) preserves the exact +/// same bit patterns at every step. +struct Mulberry32 { + state: u32, +} + +impl Mulberry32 { + fn new(seed: u32) -> Self { + Self { state: seed } + } + + fn next_f64(&mut self) -> f64 { + self.state = self.state.wrapping_add(0x6d2b79f5); + let s = self.state; + let t0 = (s ^ (s >> 15)).wrapping_mul(1 | s); + let t = t0.wrapping_add((t0 ^ (t0 >> 7)).wrapping_mul(61 | t0)) ^ t0; + ((t ^ (t >> 14)) as f64) / 4294967296.0 + } +} + +/// Fisher-Yates shuffle, in the exact same iteration/draw order as +/// `shuffleArrayInPlace` in optimiser.ts. +fn shuffle_in_place(arr: &mut [usize], rng: &mut Mulberry32) { + for i in (1..arr.len()).rev() { + let j = (rng.next_f64() * (i + 1) as f64).floor() as usize; + arr.swap(i, j); + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Graph adapter — undirected-only port of leiden/adapter.ts +// ════════════════════════════════════════════════════════════════════════ + +struct GraphAdapter { + n: usize, + /// Self-loop weight per node (single-w convention, matching adapter.ts). + self_loop: Vec, + /// Node strength (== degree for unweighted graphs). TS keeps separate + /// `strengthOut`/`strengthIn` arrays even in undirected mode (both + /// populated identically by symmetrization), but only `strengthOut` is + /// ever read by the undirected-only code paths this file ports, so a + /// single array replaces both here. + strength: Vec, + /// Node size (always 1.0 for every node reachable through this binding + /// at level 0; propagated from the previous level's community sizes at + /// coarser levels — see `build_coarse_graph`). + size: Vec, + /// Adjacency list. Undirected edges are symmetrized: an edge between i + /// and j appears once in `out_edges[i]` and once in `out_edges[j]`, in + /// first-seen order (see `build_adapter`). TS also keeps `inEdges`, but + /// it is never read by the undirected-only code paths ported here. + out_edges: Vec>, + total_weight: f64, +} + +/// One aggregated undirected-pair record while building a `GraphAdapter`: +/// accumulates edge weight for an unordered node pair while tracking which +/// direction(s) contributed a raw edge — mirrors adapter.ts's +/// `aggregateUndirectedPairs`/`recordUndirectedPairWeight`. `sum` is +/// averaged by the number of directions seen when emitted, exactly +/// replicating how the TS reference symmetrizes a graph that may store +/// independent per-direction weights (e.g. two files that import each other +/// both contribute to the same undirected community-detection edge). +struct PairAgg { + sum: f64, + seen_ab: bool, + seen_ba: bool, +} + +/// Build a `GraphAdapter` from a flat, possibly-directed/possibly-duplicate +/// edge list, mirroring `makeGraphAdapter(graph, { directed: false })` in +/// adapter.ts. Used uniformly for the level-0 graph (raw input) and every +/// coarsened level's graph (from `build_coarse_graph`), exactly like the TS +/// reference calls `makeGraphAdapter` uniformly at every level. +fn build_adapter(n: usize, raw_edges: &[(usize, usize, f64)], sizes: &[f64]) -> GraphAdapter { + let mut self_loop = vec![0.0_f64; n]; + + // First-seen insertion order is load-bearing here (see module doc): a + // `HashMap<(usize, usize), usize>` is used purely to look up the slot + // index for an already-seen pair in O(1); `pair_order`/`pair_recs` (plain + // `Vec`s, iterated below in push order) are what actually determine + // adjacency-list order. + let mut pair_index: HashMap<(usize, usize), usize> = HashMap::new(); + let mut pair_order: Vec<(usize, usize)> = Vec::new(); + let mut pair_recs: Vec = Vec::new(); + + for &(a, b, w) in raw_edges { + if a == b { + self_loop[a] += w; + continue; + } + let (i, j) = if a < b { (a, b) } else { (b, a) }; + let key = (i, j); + let idx = match pair_index.get(&key) { + Some(&existing) => existing, + None => { + let new_idx = pair_recs.len(); + pair_index.insert(key, new_idx); + pair_order.push(key); + pair_recs.push(PairAgg { + sum: 0.0, + seen_ab: false, + seen_ba: false, + }); + new_idx + } + }; + let rec = &mut pair_recs[idx]; + rec.sum += w; + if a == i { + rec.seen_ab = true; + } else { + rec.seen_ba = true; + } + } + + let mut out_edges: Vec> = vec![Vec::new(); n]; + let mut strength = vec![0.0_f64; n]; + + for (k, &(i, j)) in pair_order.iter().enumerate() { + let rec = &pair_recs[k]; + let dir_count = rec.seen_ab as u8 + rec.seen_ba as u8; + if dir_count == 0 { + continue; + } + let w = rec.sum / dir_count as f64; + if w == 0.0 { + continue; + } + out_edges[i].push((j, w)); + out_edges[j].push((i, w)); + strength[i] += w; + strength[j] += w; + } + + for v in 0..n { + let w = self_loop[v]; + if w != 0.0 { + out_edges[v].push((v, w)); + strength[v] += w; + } + } + + // Sequential left fold in index order, matching + // `strengthOut.reduce((a, b) => a + b, 0)` exactly (Rust's `Sum for f64` + // is also a sequential left fold from 0.0). + let total_weight: f64 = strength.iter().sum(); + + GraphAdapter { + n, + self_loop, + strength, + size: sizes.to_vec(), + out_edges, + total_weight, + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Partition — undirected-only port of leiden/partition.ts + +// leiden/aggregate-helpers.ts +// ════════════════════════════════════════════════════════════════════════ + +struct Partition { + n: usize, + node_community: Vec, + community_count: usize, + community_total_size: Vec, + community_node_count: Vec, + community_internal_edge_weight: Vec, + community_total_strength: Vec, + /* scratch arrays for neighbor accumulation — fixed at size `n` for the + lifetime of a Partition; see module doc / `move_node_to_community` doc + for why these never need to grow (unlike the four aggregate arrays + above, which can grow via `resize_communities` after + `split_disconnected_communities` mints ids beyond the initial `n`). */ + candidate_communities: Vec, + candidate_count: usize, + neighbor_edge_weight_to_community: Vec, + is_candidate_community: Vec, + capacity_growth_factor: f64, +} + +impl Partition { + fn new(n: usize, capacity_growth_factor: f64) -> Self { + Partition { + n, + node_community: (0..n).collect(), + community_count: n, + community_total_size: vec![0.0; n], + community_node_count: vec![0; n], + community_internal_edge_weight: vec![0.0; n], + community_total_strength: vec![0.0; n], + candidate_communities: vec![0; n], + candidate_count: 0, + neighbor_edge_weight_to_community: vec![0.0; n], + is_candidate_community: vec![false; n], + capacity_growth_factor, + } + } + + /// Full aggregate recompute from `node_community`. Mirrors + /// `initializeAggregates`/`accumulateNodeAggregates`/ + /// `accumulateInternalEdgeWeights` (undirected branches only). + fn initialize_aggregates(&mut self, g: &GraphAdapter) { + for v in self.community_total_size.iter_mut() { + *v = 0.0; + } + for v in self.community_node_count.iter_mut() { + *v = 0; + } + for v in self.community_internal_edge_weight.iter_mut() { + *v = 0.0; + } + for v in self.community_total_strength.iter_mut() { + *v = 0.0; + } + + for i in 0..self.n { + let c = self.node_community[i]; + self.community_total_size[c] += g.size[i]; + self.community_node_count[c] += 1; + self.community_total_strength[c] += g.strength[i]; + if g.self_loop[i] != 0.0 { + self.community_internal_edge_weight[c] += g.self_loop[i]; + } + } + // Intra-community non-self-loop edges, each counted once (j > i) -- + // matches accumulateInternalEdgeWeights's undirected branch. + for i in 0..self.n { + let ci = self.node_community[i]; + for &(j, w) in &g.out_edges[i] { + if j <= i { + continue; + } + if ci == self.node_community[j] { + self.community_internal_edge_weight[ci] += w; + } + } + } + } + + fn reset_scratch(&mut self) { + for i in 0..self.candidate_count { + let c = self.candidate_communities[i]; + self.is_candidate_community[c] = false; + self.neighbor_edge_weight_to_community[c] = 0.0; + } + self.candidate_count = 0; + } + + fn touch_candidate(&mut self, c: usize) { + if self.is_candidate_community[c] { + return; + } + self.is_candidate_community[c] = true; + self.candidate_communities[self.candidate_count] = c; + self.candidate_count += 1; + } + + /// Mirrors `accumulateNeighborCommunityEdgeWeights`'s undirected branch: + /// always touches the node's own community first, then its neighbors' + /// communities in `out_edges[v]` order (which includes a self-loop entry + /// if present, at the end — see `build_adapter`). Returns the number of + /// distinct candidate communities touched. + fn accumulate_neighbor_community_edge_weights(&mut self, g: &GraphAdapter, v: usize) -> usize { + self.reset_scratch(); + let ci = self.node_community[v]; + self.touch_candidate(ci); + for &(j, w) in &g.out_edges[v] { + let cj = self.node_community[j]; + self.touch_candidate(cj); + self.neighbor_edge_weight_to_community[cj] += w; + } + self.candidate_count + } + + fn get_candidate_community_at(&self, i: usize) -> usize { + self.candidate_communities[i] + } + + fn get_neighbor_edge_weight_to_community(&self, c: usize) -> f64 { + if c < self.neighbor_edge_weight_to_community.len() { + self.neighbor_edge_weight_to_community[c] + } else { + 0.0 + } + } + + fn get_community_total_strength(&self, c: usize) -> f64 { + if c < self.community_total_strength.len() { + self.community_total_strength[c] + } else { + 0.0 + } + } + + fn get_community_node_count(&self, c: usize) -> usize { + if c < self.community_node_count.len() { + self.community_node_count[c] + } else { + 0 + } + } + + fn get_community_members(&self) -> Vec> { + let mut comms: Vec> = vec![Vec::new(); self.community_count]; + for i in 0..self.n { + comms[self.node_community[i]].push(i); + } + comms + } + + /// Mirrors `moveNodeToCommunity`'s undirected branch. `new_c` must + /// already be `< community_count` — unlike TS, this never grows on a + /// brand-new id, because `allowNewCommunity` (the only way TS reaches + /// that branch) is never enabled by this binding's option surface (see + /// module doc). + fn move_node_to_community(&mut self, g: &GraphAdapter, v: usize, new_c: usize) -> bool { + let old_c = self.node_community[v]; + if old_c == new_c { + return false; + } + let strength_v = g.strength[v]; + let self_loop_w = g.self_loop[v]; + let node_sz = g.size[v]; + + self.community_node_count[old_c] -= 1; + self.community_node_count[new_c] += 1; + self.community_total_size[old_c] -= node_sz; + self.community_total_size[new_c] += node_sz; + + self.community_total_strength[old_c] -= strength_v; + self.community_total_strength[new_c] += strength_v; + + // outToOld/outToNew already include the self-loop weight (self-loops + // live in out_edges), doubled here to match the "2*weight" internal + // edge convention, with the self-loop weight added back once more -- + // see applyMoveInternalEdgeWeightDeltaUndirected in partition.ts. + let weight_to_old = self.get_neighbor_edge_weight_to_community(old_c); + let weight_to_new = self.get_neighbor_edge_weight_to_community(new_c); + self.community_internal_edge_weight[old_c] -= 2.0 * weight_to_old + self_loop_w; + self.community_internal_edge_weight[new_c] += 2.0 * weight_to_new + self_loop_w; + + self.node_community[v] = new_c; + true + } + + /// Grow the four aggregate arrays (not the fixed-size scratch arrays -- + /// see their field doc) to fit at least `new_count` communities. + /// Reachable only via `split_disconnected_communities`, which can mint + /// ids beyond the initial `n` allocation when a post-refinement + /// community turns out to be disconnected. + fn ensure_capacity(&mut self, new_count: usize) { + if new_count <= self.community_total_size.len() { + return; + } + let grow_to = new_count.max( + ((self.community_total_size.len() as f64) * self.capacity_growth_factor).ceil() + as usize, + ); + self.community_total_size.resize(grow_to, 0.0); + self.community_node_count.resize(grow_to, 0); + self.community_internal_edge_weight.resize(grow_to, 0.0); + self.community_total_strength.resize(grow_to, 0.0); + } + + fn resize_communities(&mut self, new_count: usize) { + self.ensure_capacity(new_count); + self.community_count = new_count; + } + + /// Renumber communities: default (only reachable) compaction mode -- + /// descending total size, tie-broken by descending node count then + /// ascending original id. Mirrors `compactCommunityIds()` with no + /// options (`preserveLabels`/`keepOldOrder` are unreachable here). + fn compact_community_ids(&mut self, g: &GraphAdapter) { + let ids = sorted_nonempty_community_ids( + self.community_count, + &self.community_node_count, + &self.community_total_size, + ); + + let mut new_id = vec![0usize; self.community_count]; + for (i, &c) in ids.iter().enumerate() { + new_id[c] = i; + } + for slot in self.node_community.iter_mut() { + *slot = new_id[*slot]; + } + + self.community_count = ids.len(); + self.community_total_size = vec![0.0; self.community_count]; + self.community_node_count = vec![0; self.community_count]; + self.community_internal_edge_weight = vec![0.0; self.community_count]; + self.community_total_strength = vec![0.0; self.community_count]; + self.initialize_aggregates(g); + } +} + +/// Non-empty community ids in ascending original order, then sorted +/// descending by size / node count / ascending id. Extracted as a free +/// function (taking plain slices) to keep the sort's borrows trivially +/// disjoint from the `&mut self` mutations that follow it in +/// `compact_community_ids`. +fn sorted_nonempty_community_ids( + community_count: usize, + node_count: &[usize], + total_size: &[f64], +) -> Vec { + let mut ids: Vec = (0..community_count) + .filter(|&c| node_count[c] > 0) + .collect(); + ids.sort_by(|&a, &b| { + total_size[b] + .partial_cmp(&total_size[a]) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| node_count[b].cmp(&node_count[a])) + .then_with(|| a.cmp(&b)) + }); + ids +} + +// ════════════════════════════════════════════════════════════════════════ +// Modularity — undirected-only port of leiden/modularity.ts +// ════════════════════════════════════════════════════════════════════════ + +/// Mirrors `diffModularity`'s undirected branch exactly. +fn diff_modularity(partition: &Partition, g: &GraphAdapter, v: usize, c: usize, gamma: f64) -> f64 { + let old_c = partition.node_community[v]; + if c == old_c { + return 0.0; + } + let k_v = g.strength[v]; + let m2 = g.total_weight; + let k_v_in_new = partition.get_neighbor_edge_weight_to_community(c); + let k_v_in_old = partition.get_neighbor_edge_weight_to_community(old_c); + let w_tot_new = partition.get_community_total_strength(c); + let w_tot_old = partition.community_total_strength[old_c]; + let gain_remove = -(k_v_in_old / m2 - (gamma * (k_v * w_tot_old)) / (m2 * m2)); + let gain_add = k_v_in_new / m2 - (gamma * (k_v * w_tot_new)) / (m2 * m2); + gain_remove + gain_add +} + +/// Standard Newman-Girvan modularity, mirrors `qualityModularity`'s +/// undirected branch. Used only for the final quality() computation on the +/// original (level-0) graph, always at gamma=1.0 regardless of the +/// optimization resolution (see `compute_final_modularity`). +fn quality_modularity( + community_count: usize, + community_internal_edge_weight: &[f64], + community_total_strength: &[f64], + m2: f64, + gamma: f64, +) -> f64 { + let mut sum = 0.0_f64; + for c in 0..community_count { + let lc = community_internal_edge_weight[c]; + let dc = community_total_strength[c]; + sum += (2.0 * lc) / m2 - (gamma * (dc * dc)) / (m2 * m2); + } + sum +} + +// ════════════════════════════════════════════════════════════════════════ +// Optimiser — undirected-only, "neighbors"-strategy-only, refine-always-on +// port of leiden/optimiser.ts +// ════════════════════════════════════════════════════════════════════════ + +/// Evaluate every touched candidate community for `node_index` and return +/// the best (community, gain) pair — mirrors `findBestCommunityMove`'s +/// "neighbors" branch (the only candidate strategy this binding reaches; +/// `allowNewCommunity`'s new-community probe is also unreachable here). +fn find_best_community_move( + partition: &Partition, + g: &GraphAdapter, + node_index: usize, + candidate_count: usize, + resolution: f64, +) -> (usize, f64) { + let own = partition.node_community[node_index]; + let mut best_c = own; + let mut best_gain = 0.0_f64; + for t in 0..candidate_count { + let c = partition.get_candidate_community_at(t); + if c == own { + continue; + } + let gain = diff_modularity(partition, g, node_index, c, resolution); + if gain > best_gain { + best_gain = gain; + best_c = c; + } + } + (best_c, best_gain) +} + +/// Greedy local-move phase: iterate nodes in a shuffled order, moving each +/// to the best candidate community, until no improvement or +/// `max_local_passes` is reached. Mirrors `runLocalMovePhase` exactly. +fn run_local_move_phase( + g: &GraphAdapter, + partition: &mut Partition, + cfg: &LeidenConfig, + rng: &mut Mulberry32, +) { + let mut order: Vec = (0..g.n).collect(); + let mut improved = true; + let mut local_passes = 0usize; + while improved { + improved = false; + local_passes += 1; + shuffle_in_place(&mut order, rng); + for &node_index in &order { + let candidate_count = + partition.accumulate_neighbor_community_edge_weights(g, node_index); + let (best_c, best_gain) = + find_best_community_move(partition, g, node_index, candidate_count, cfg.resolution); + if best_c != partition.node_community[node_index] && best_gain > LEIDEN_GAIN_EPSILON { + partition.move_node_to_community(g, node_index, best_c); + improved = true; + } + } + if local_passes >= cfg.max_local_passes { + break; + } + } +} + +/// Boltzmann probabilistic candidate selection (Algorithm 3, Traag et al. +/// 2019). Returns `None` when the node should stay a singleton (TS's `-1` +/// sentinel), or `Some(community)` otherwise. Mirrors +/// `boltzmannSelectCandidate` exactly. +fn boltzmann_select_candidate( + cand_len: usize, + theta: f64, + rng: &mut Mulberry32, + cand_c: &[usize], + cand_gain: &[f64], + cand_weight: &mut [f64], +) -> Option { + let mut max_gain = 0.0_f64; + for &gain in cand_gain.iter().take(cand_len) { + if gain > max_gain { + max_gain = gain; + } + } + let stay_weight = ((0.0 - max_gain) / theta).exp(); + let mut total_weight = stay_weight; + for i in 0..cand_len { + cand_weight[i] = ((cand_gain[i] - max_gain) / theta).exp(); + total_weight += cand_weight[i]; + } + + let r = rng.next_f64() * total_weight; + if r < stay_weight { + return None; + } + let mut cumulative = stay_weight; + for i in 0..cand_len { + cumulative += cand_weight[i]; + if r < cumulative { + return Some(cand_c[i]); + } + } + Some(cand_c[cand_len - 1]) +} + +/// True Leiden refinement phase: singleton start, singleton guard (only +/// still-alone nodes may merge), single randomized pass, Boltzmann +/// probabilistic selection scoped to candidates sharing the same +/// macro-community. Mirrors `refineWithinCoarseCommunities` exactly. +fn refine_within_coarse_communities( + g: &GraphAdapter, + base_part: &Partition, + cfg: &LeidenConfig, + rng: &mut Mulberry32, +) -> Partition { + let mut p = Partition::new(g.n, cfg.capacity_growth_factor); + p.initialize_aggregates(g); + + // Macro-community membership per node, from the already-compacted + // local-move partition. TS's `commMacro` is built by copying + // `basePart.nodeCommunity` element-for-element into an array sized + // `p.communityCount` (== g.n for a fresh singleton partition), i.e. it + // is just a clone of `macro` at this point — reproduced directly here. + let comm_macro: Vec = base_part.node_community.clone(); + + let theta = cfg.refinement_theta; + + let mut order: Vec = (0..g.n).collect(); + shuffle_in_place(&mut order, rng); + + let mut cand_c = vec![0usize; g.n]; + let mut cand_gain = vec![0.0_f64; g.n]; + let mut cand_weight = vec![0.0_f64; g.n]; + + for &v in &order { + // Singleton guard: only nodes still alone in their community may merge. + let cur_c = p.node_community[v]; + if p.get_community_node_count(cur_c) > 1 { + continue; + } + + let macro_v = comm_macro[v]; + let touched = p.accumulate_neighbor_community_edge_weights(g, v); + + let mut cand_len = 0usize; + for t in 0..touched { + let c = p.get_candidate_community_at(t); + if c == p.node_community[v] { + continue; + } + if comm_macro[c] != macro_v { + continue; + } + let gain = diff_modularity(&p, g, v, c, cfg.resolution); + if gain > LEIDEN_GAIN_EPSILON { + cand_c[cand_len] = c; + cand_gain[cand_len] = gain; + cand_len += 1; + } + } + if cand_len == 0 { + continue; + } + + let chosen = + boltzmann_select_candidate(cand_len, theta, rng, &cand_c, &cand_gain, &mut cand_weight); + if let Some(c) = chosen { + p.move_node_to_community(g, v, c); + } + } + + p +} + +/// BFS over the subgraph induced by `in_community`, starting from `start`. +/// Mirrors `bfsComponent` (the undirected-only branch — `out_edges` alone +/// carries the full symmetrized adjacency). +fn bfs_component( + g: &GraphAdapter, + start: usize, + in_community: &[bool], + visited: &mut [bool], +) -> Vec { + let mut queue = vec![start]; + visited[start] = true; + let mut head = 0usize; + while head < queue.len() { + let v = queue[head]; + head += 1; + for &(to, _w) in &g.out_edges[v] { + if in_community[to] && !visited[to] { + visited[to] = true; + queue.push(to); + } + } + } + queue +} + +/// Post-refinement connectivity check: split any community with more than +/// one connected component into its components, reassigning secondary +/// components to fresh community ids. Mirrors `splitDisconnectedCommunities` +/// exactly (O(V+E), since communities partition V). +fn split_disconnected_communities(g: &GraphAdapter, partition: &mut Partition) { + let n = g.n; + let members = partition.get_community_members(); + let mut next_c = partition.community_count; + let mut did_split = false; + + let mut visited = vec![false; n]; + let mut in_community = vec![false; n]; + + for nodes in &members { + if nodes.len() <= 1 { + continue; + } + for &nd in nodes { + in_community[nd] = true; + } + + let mut component_count = 0usize; + for &start in nodes { + if visited[start] { + continue; + } + component_count += 1; + let component = bfs_component(g, start, &in_community, &mut visited); + if component_count > 1 { + let new_c = next_c; + next_c += 1; + for &q in &component { + partition.node_community[q] = new_c; + } + did_split = true; + } + } + + for &nd in nodes { + in_community[nd] = false; + visited[nd] = false; + } + } + + if did_split { + partition.resize_communities(next_c); + partition.initialize_aggregates(g); + } +} + +/// One level's outcome: the effective (post-refinement) partition's +/// node→community assignment and per-community sizes (needed by +/// `build_coarse_graph`), plus whether this level made no progress at all +/// (both the macro local-move phase and the refined/split partition ended +/// up fully singleton) — mirrors `runLevel`'s `{ effectivePartition, +/// terminate }`. +struct LevelOutcome { + node_community: Vec, + community_total_size: Vec, + community_count: usize, + terminate: bool, +} + +fn run_level(g: &GraphAdapter, cfg: &LeidenConfig, rng: &mut Mulberry32) -> LevelOutcome { + let mut partition = Partition::new(g.n, cfg.capacity_growth_factor); + partition.initialize_aggregates(g); + + run_local_move_phase(g, &mut partition, cfg, rng); + partition.compact_community_ids(g); + let macro_community_count = partition.community_count; + + // Leiden refinement always runs here: `louvainCommunities`'s option + // surface never threads a `refine` flag through to `detectClusters`, so + // the TS reference always takes `options.refine !== false` => true. This + // is the step that makes the algorithm Leiden rather than plain Louvain. + let mut refined = refine_within_coarse_communities(g, &partition, cfg, rng); + split_disconnected_communities(g, &mut refined); + refined.compact_community_ids(g); + + let effective_community_count = refined.community_count; + let terminate = macro_community_count == g.n && effective_community_count == g.n; + + LevelOutcome { + node_community: refined.node_community, + community_total_size: refined.community_total_size, + community_count: refined.community_count, + terminate, + } +} + +/// Build the next level's coarse graph: each community becomes a single +/// node, sized by its aggregate size from the previous level. Mirrors +/// `buildCoarseGraph` *plus* the next level's `makeGraphAdapter(coarse, { +/// directed: false })` re-read of that coarse `CodeGraph` — both stages +/// matter for byte-identical output, not just the edge-weight arithmetic: +/// +/// Stage 1 (mirrors `buildCoarseGraph`'s `acc: Map`): +/// accumulate a first-seen-order, *directional*-key (`cu:cv` and `cv:cu` as +/// distinct entries) sum over `g.out_edges`. +/// +/// Stage 2 (mirrors constructing the coarse `CodeGraph` via +/// `coarse.addNode(String(c), ...)` for `c` in ascending `0..communityCount` +/// *before* any edges are added, then `coarse.addEdge(cu, cv, ...)` per +/// stage-1 entry): because `addNode` pre-populates `_successors` with keys +/// "0","1",...,"commCount-1" in that ascending order, and `CodeGraph`'s +/// `Map`-backed adjacency never reorders an *existing* key on a later write, +/// each community's neighbor list is ordered by when a partner was *first* +/// touched (from either direction) — not by stage-1's raw discovery order. +/// Getting this wrong (e.g. reusing stage-1's order directly) is exactly +/// the bug this fix addresses: it does not change *which* nodes end up +/// grouped together for graphs with an unambiguous optimum (small hand-built +/// fixtures all still passed), but it does change candidate/tie-breaking +/// order from the second coarsening level onward, which the resolution +/// benchmark and this repo's own ~700-8800 node dependency graphs surfaced +/// as a real, reproducible native/JS divergence. +/// +/// Stage 3 (mirrors `_undirectedEdges()`'s dedup traversal of the coarse +/// `CodeGraph`, which is what the *next* level's `makeGraphAdapter` actually +/// iterates): walk communities in ascending order, each one's neighbor list +/// in its stage-2 discovery order, yielding each canonical pair once. The +/// result is fed through the same `build_adapter` used for level 0 — each +/// pair now appears exactly once (dirCount will always be 1), which is +/// mathematically the same value stage-1's directional sums would average +/// to for this binding's integer-only edge weights (see the reciprocal-edge +/// test), but replicating the real order removes any doubt. +fn build_coarse_graph( + g: &GraphAdapter, + node_community: &[usize], + community_total_size: &[f64], + community_count: usize, +) -> GraphAdapter { + let sizes: Vec = community_total_size[0..community_count].to_vec(); + + // Stage 1: `acc`-equivalent — directional-key, first-seen-order sum. + let mut acc_index: HashMap<(usize, usize), usize> = HashMap::new(); + let mut acc_order: Vec<(usize, usize)> = Vec::new(); + let mut acc_values: Vec = Vec::new(); + + for i in 0..g.n { + let cu = node_community[i]; + for &(j, w) in &g.out_edges[i] { + let cv = node_community[j]; + // Undirected: each non-self edge (i,j) appears in both + // out_edges[i] and out_edges[j]. For intra-community edges + // (cu==cv), skip the reverse occurrence to avoid inflating the + // coarse self-loop weight by 2x (matches the `j < i` guard in + // optimiser.ts's buildCoarseGraph). + if cu == cv && j < i { + continue; + } + let key = (cu, cv); + match acc_index.get(&key) { + Some(&idx) => acc_values[idx] += w, + None => { + let idx = acc_values.len(); + acc_index.insert(key, idx); + acc_order.push(key); + acc_values.push(w); + } + } + } + } + + // Stage 2: per-community neighbor discovery order (mirrors the coarse + // CodeGraph's per-node adjacency Map insertion order) + last-write-wins + // weight per canonical (undirected) pair (mirrors addEdge overwriting an + // existing Map entry's value without moving its position). + let mut neighbor_order: Vec> = vec![Vec::new(); community_count]; + let mut neighbor_seen: Vec> = + vec![std::collections::HashSet::new(); community_count]; + let mut final_weight: HashMap<(usize, usize), f64> = HashMap::new(); + + for (idx, &(cu, cv)) in acc_order.iter().enumerate() { + let w = acc_values[idx]; + if cu == cv { + if neighbor_seen[cu].insert(cu) { + neighbor_order[cu].push(cu); + } + final_weight.insert((cu, cu), w); + continue; + } + let (lo, hi) = if cu < cv { (cu, cv) } else { (cv, cu) }; + final_weight.insert((lo, hi), w); + if neighbor_seen[cu].insert(cv) { + neighbor_order[cu].push(cv); + } + if neighbor_seen[cv].insert(cu) { + neighbor_order[cv].push(cu); + } + } + + // Stage 3: ascending community order, each community's neighbors in + // discovery order, canonical-pair dedup — mirrors `_undirectedEdges()`. + let mut raw_edges: Vec<(usize, usize, f64)> = Vec::new(); + let mut yielded: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); + for (cu, neighbors) in neighbor_order.iter().enumerate() { + for &cv in neighbors { + let (lo, hi) = if cu < cv { (cu, cv) } else { (cv, cu) }; + if !yielded.insert((lo, hi)) { + continue; + } + let w = *final_weight + .get(&(lo, hi)) + .expect("weight recorded in stage 2"); + raw_edges.push((cu, cv, w)); + } + } + + build_adapter(community_count, &raw_edges, &sizes) +} + +/// Run the full multi-level Leiden pipeline and return the final +/// original-node → final-community mapping. Mirrors +/// `runLouvainUndirectedModularity`'s level loop exactly. +fn run_leiden(base_graph: &GraphAdapter, cfg: &LeidenConfig, seed: u32) -> Vec { + let mut rng = Mulberry32::new(seed); + let orig_n = base_graph.n; + let mut original_to_current: Vec = (0..orig_n).collect(); + + let mut coarse_graph: Option = None; + + for _level in 0..cfg.max_levels { + let graph_ref: &GraphAdapter = coarse_graph.as_ref().unwrap_or(base_graph); + + let outcome = run_level(graph_ref, cfg, &mut rng); + + for slot in original_to_current.iter_mut() { + *slot = outcome.node_community[*slot]; + } + + if outcome.terminate { + break; + } + + let next_coarse = build_coarse_graph( + graph_ref, + &outcome.node_community, + &outcome.community_total_size, + outcome.community_count, + ); + coarse_graph = Some(next_coarse); + } + + original_to_current +} + +/// Final quality(): recompute aggregates on the ORIGINAL (level-0) graph +/// using the final fine→coarse mapping, then evaluate standard modularity +/// at gamma=1.0 regardless of the optimization resolution — mirrors +/// `detectClusters().quality()`'s modularity branch (`buildOriginalPartition` +/// combined with `qualityModularity(part, baseGraph, 1.0)`) exactly. +/// Computing on the original graph (rather than the last coarse level) +/// matters, since the modularity null model depends on the degree +/// distribution, which changes after coarsening. +fn compute_final_modularity(base: &GraphAdapter, original_to_current: &[usize]) -> f64 { + let community_count = original_to_current + .iter() + .copied() + .max() + .map(|m| m + 1) + .unwrap_or(0); + if community_count == 0 || base.total_weight == 0.0 { + return 0.0; + } + + let mut total_strength = vec![0.0_f64; community_count]; + let mut internal_edge_weight = vec![0.0_f64; community_count]; + + for (i, &c) in original_to_current.iter().enumerate().take(base.n) { + total_strength[c] += base.strength[i]; + if base.self_loop[i] != 0.0 { + internal_edge_weight[c] += base.self_loop[i]; + } + } + for i in 0..base.n { + let ci = original_to_current[i]; + for &(j, w) in &base.out_edges[i] { + if j <= i { + continue; + } + if ci == original_to_current[j] { + internal_edge_weight[ci] += w; + } + } + } + + quality_modularity( + community_count, + &internal_edge_weight, + &total_strength, + base.total_weight, + 1.0, + ) +} + +// ════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap as StdHashMap; + + fn edge(src: &str, tgt: &str) -> GraphEdge { + GraphEdge { + source: src.to_string(), + target: tgt.to_string(), + } + } + + fn assignments_map(result: &LeidenCommunitiesResult) -> StdHashMap { + result + .assignments + .iter() + .map(|a| (a.node.clone(), a.community)) + .collect() + } + + #[test] + fn test_leiden_empty() { + let result = leiden_communities(vec![], vec![], None, None, None, None, None, None); + assert!(result.assignments.is_empty()); + assert_eq!(result.modularity, 0.0); + } + + #[test] + fn test_leiden_two_cliques() { + let edges = vec![ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("d", "e"), + edge("e", "f"), + edge("f", "d"), + ]; + let nodes: Vec = vec!["a", "b", "c", "d", "e", "f"] + .into_iter() + .map(String::from) + .collect(); + let result = leiden_communities(edges, nodes, None, None, None, None, None, None); + + let map = assignments_map(&result); + assert_eq!(map["a"], map["b"]); + assert_eq!(map["b"], map["c"]); + assert_eq!(map["d"], map["e"]); + assert_eq!(map["e"], map["f"]); + assert_ne!(map["a"], map["d"]); + assert!(result.modularity > 0.0); + } + + #[test] + fn test_leiden_single_component() { + let edges = vec![edge("a", "b"), edge("a", "c"), edge("b", "c")]; + let nodes: Vec = vec!["a", "b", "c"].into_iter().map(String::from).collect(); + let result = leiden_communities(edges, nodes, None, None, None, None, None, None); + let map = assignments_map(&result); + assert_eq!(map["a"], map["b"]); + assert_eq!(map["b"], map["c"]); + } + + /// Regression test mirroring #1734 (originally filed against the classic + /// Louvain implementation this file replaces): repeated calls with a + /// fixed seed on a graph engineered to force a genuine modularity-gain + /// tie must produce byte-identical assignments and modularity every + /// time. This graph is symmetric by construction — three disjoint + /// triangles plus a bridge node connected with equal weight to one + /// member of each triangle — so moving the bridge node into any of the + /// three triangles yields the exact same modularity gain. + #[test] + fn test_leiden_deterministic_across_repeated_calls_with_tie() { + let edges = vec![ + edge("a1", "a2"), + edge("a2", "a3"), + edge("a3", "a1"), + edge("b1", "b2"), + edge("b2", "b3"), + edge("b3", "b1"), + edge("c1", "c2"), + edge("c2", "c3"), + edge("c3", "c1"), + edge("x", "a1"), + edge("x", "b1"), + edge("x", "c1"), + ]; + let nodes: Vec = vec!["a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "x"] + .into_iter() + .map(String::from) + .collect(); + + let mut snapshots: Vec<(String, f64)> = Vec::new(); + for _ in 0..30 { + let result = leiden_communities( + edges.clone(), + nodes.clone(), + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + let mut pairs: Vec = result + .assignments + .iter() + .map(|a| format!("{}:{}", a.node, a.community)) + .collect(); + pairs.sort(); + snapshots.push((pairs.join(","), result.modularity)); + } + + let first = &snapshots[0]; + for (i, snapshot) in snapshots.iter().enumerate().skip(1) { + assert_eq!( + snapshot.0, first.0, + "run {i} produced a different assignment than run 0 — tie-breaking is not deterministic" + ); + assert_eq!( + snapshot.1, first.1, + "run {i} produced a different modularity than run 0" + ); + } + } + + /// A "mutual import" style graph: both (a,b) and (b,a) are present as + /// independent directed edges (as `graph.toEdgeArray()` would emit for a + /// directed CodeGraph with edges in both directions between two nodes). + /// These must be treated as ONE undirected edge of weight 1 (averaged), + /// not weight 2 (summed) — the classic Louvain implementation this file + /// replaces summed reciprocal edges instead of averaging them, an + /// additional (independent) source of native/JS divergence beyond the + /// algorithm mismatch that issue #1804 is about. Verified by comparing + /// against a graph with the exact same structure but only ONE direction + /// per edge: both must produce identical modularity, since averaging + /// duplicate reciprocal edges must be equivalent to de-duplicating them. + #[test] + fn test_leiden_reciprocal_edges_are_averaged_not_summed() { + let nodes: Vec = vec!["a", "b", "c", "d"] + .into_iter() + .map(String::from) + .collect(); + + let reciprocal_edges = vec![ + edge("a", "b"), + edge("b", "a"), + edge("b", "c"), + edge("c", "b"), + edge("c", "d"), + edge("d", "c"), + ]; + let single_direction_edges = vec![edge("a", "b"), edge("b", "c"), edge("c", "d")]; + + let reciprocal_result = leiden_communities( + reciprocal_edges, + nodes.clone(), + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + let single_result = leiden_communities( + single_direction_edges, + nodes, + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + + assert_eq!(reciprocal_result.modularity, single_result.modularity); + assert_eq!( + assignments_map(&reciprocal_result), + assignments_map(&single_result) + ); + } + + /// A node with a self-loop must have that weight counted toward its own + /// degree/strength (and hence total_weight / modularity), matching + /// adapter.ts's single-w self-loop convention — not silently dropped + /// (the classic Louvain implementation this file replaces dropped + /// self-loops entirely). + #[test] + fn test_leiden_self_loop_contributes_to_modularity() { + let edges = vec![ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("a", "a"), + ]; + let nodes: Vec = vec!["a", "b", "c"].into_iter().map(String::from).collect(); + let with_self_loop = leiden_communities( + edges, + nodes.clone(), + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + + let edges_no_loop = vec![edge("a", "b"), edge("b", "c"), edge("c", "a")]; + let without_self_loop = leiden_communities( + edges_no_loop, + nodes, + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + + assert_ne!(with_self_loop.modularity, without_self_loop.modularity); + } + + /// `refinementTheta`/`maxLevels`/`maxLocalPasses`/`capacityGrowthFactor` + /// must actually be threaded through (the classic Louvain implementation + /// this file replaces silently ignored all four). + #[test] + fn test_leiden_accepts_all_leiden_options() { + let edges = vec![ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("x", "y"), + edge("y", "z"), + edge("z", "x"), + edge("c", "x"), + ]; + let nodes: Vec = vec!["a", "b", "c", "x", "y", "z"] + .into_iter() + .map(String::from) + .collect(); + + let result = leiden_communities( + edges, + nodes, + Some(1.0), + Some(42), + Some(50), + Some(20), + Some(1.0), + Some(1.5), + ); + assert_eq!(result.assignments.len(), 6); + } +} diff --git a/crates/codegraph-core/src/graph/algorithms/louvain.rs b/crates/codegraph-core/src/graph/algorithms/louvain.rs deleted file mode 100644 index 165a5c27b..000000000 --- a/crates/codegraph-core/src/graph/algorithms/louvain.rs +++ /dev/null @@ -1,401 +0,0 @@ -use std::collections::HashMap; - -use crate::shared::constants::{ - DEFAULT_RANDOM_SEED, LOUVAIN_MAX_LEVELS, LOUVAIN_MAX_PASSES, LOUVAIN_MIN_GAIN, -}; -use crate::types::GraphEdge; -use napi_derive::napi; - -#[napi(object)] -#[derive(Debug, Clone)] -pub struct CommunityAssignment { - pub node: String, - pub community: i32, -} - -#[napi(object)] -#[derive(Debug, Clone)] -pub struct LouvainResult { - pub assignments: Vec, - pub modularity: f64, -} - -/// Classic Louvain algorithm for undirected community detection. -/// -/// Takes an edge list and treats it as undirected. Optimizes modularity -/// via the standard two-phase Louvain approach: -/// 1. Local phase: greedily move nodes to maximize modularity gain -/// 2. Aggregation phase: collapse communities into super-nodes and repeat -#[napi] -pub fn louvain_communities( - edges: Vec, - node_ids: Vec, - resolution: Option, - random_seed: Option, -) -> LouvainResult { - if edges.is_empty() || node_ids.is_empty() { - return LouvainResult { - assignments: vec![], - modularity: 0.0, - }; - } - louvain_impl( - &edges, - &node_ids, - resolution.unwrap_or(1.0), - random_seed.unwrap_or(DEFAULT_RANDOM_SEED), - ) -} - -/// Internal state for the Louvain multi-level loop. -struct LouvainState { - cur_n: usize, - cur_edges: HashMap<(usize, usize), f64>, - cur_degree: Vec, - original_community: Vec, - rng_state: u32, -} - -/// Build the initial index-based edge map and degree vector from raw edges. -fn louvain_init( - edges: &[GraphEdge], - node_ids: &[String], - seed: u32, -) -> (HashMap<(usize, usize), f64>, f64, LouvainState) { - let n = node_ids.len(); - let mut id_to_idx: HashMap<&str, usize> = HashMap::with_capacity(n); - for (i, id) in node_ids.iter().enumerate() { - id_to_idx.insert(id.as_str(), i); - } - - // Build undirected weighted edge list (deduplicate, merge parallel edges) - let mut edge_map: HashMap<(usize, usize), f64> = HashMap::new(); - for edge in edges { - if let (Some(&src), Some(&tgt)) = ( - id_to_idx.get(edge.source.as_str()), - id_to_idx.get(edge.target.as_str()), - ) { - if src == tgt { - continue; - } - let key = if src < tgt { (src, tgt) } else { (tgt, src) }; - *edge_map.entry(key).or_insert(0.0) += 1.0; - } - } - - let total_weight: f64 = edge_map.values().sum(); - - let mut cur_degree: Vec = vec![0.0; n]; - for (&(src, tgt), &w) in &edge_map { - cur_degree[src] += w; - cur_degree[tgt] += w; - } - - let rng_state = if seed == 0 { 1 } else { seed }; - - let state = LouvainState { - cur_n: n, - cur_edges: edge_map.clone(), - cur_degree, - original_community: (0..n).collect(), - rng_state, - }; - - (edge_map, total_weight, state) -} - -/// Xorshift32 PRNG step. -fn xorshift32(state: &mut u32) -> u32 { - *state ^= *state << 13; - *state ^= *state >> 17; - *state ^= *state << 5; - *state -} - -/// Local move phase: greedily reassign nodes to communities to maximize modularity. -/// Returns true if any node moved. -fn local_move_phase( - state: &mut LouvainState, - resolution: f64, - total_m2: f64, -) -> (Vec, bool) { - let cur_n = state.cur_n; - - // Build adjacency list - let mut adj: Vec> = vec![vec![]; cur_n]; - for (&(src, tgt), &w) in &state.cur_edges { - adj[src].push((tgt, w)); - adj[tgt].push((src, w)); - } - - let mut level_comm: Vec = (0..cur_n).collect(); - let mut comm_total: Vec = state.cur_degree.clone(); - - // Shuffle visit order with seeded RNG - let mut order: Vec = (0..cur_n).collect(); - for i in (1..order.len()).rev() { - let j = xorshift32(&mut state.rng_state) as usize % (i + 1); - order.swap(i, j); - } - - let mut any_moved = false; - for _pass in 0..LOUVAIN_MAX_PASSES { - let mut pass_moved = false; - for &node in &order { - let node_comm = level_comm[node]; - let node_deg = state.cur_degree[node]; - - let mut comm_w: HashMap = HashMap::new(); - for &(neighbor, w) in &adj[node] { - *comm_w.entry(level_comm[neighbor]).or_insert(0.0) += w; - } - - let w_own = *comm_w.get(&node_comm).unwrap_or(&0.0); - let remove_cost = - w_own - resolution * node_deg * (comm_total[node_comm] - node_deg) / total_m2; - - let mut best_comm = node_comm; - let mut best_gain: f64 = 0.0; - - for (&target_comm, &w_target) in &comm_w { - if target_comm == node_comm { - continue; - } - let gain = w_target - - resolution * node_deg * comm_total[target_comm] / total_m2 - - remove_cost; - if gain > best_gain { - best_gain = gain; - best_comm = target_comm; - } - } - - if best_comm != node_comm && best_gain > LOUVAIN_MIN_GAIN { - comm_total[node_comm] -= node_deg; - comm_total[best_comm] += node_deg; - level_comm[node] = best_comm; - pass_moved = true; - any_moved = true; - } - } - if !pass_moved { - break; - } - } - - (level_comm, any_moved) -} - -/// Aggregation phase: renumber communities, compose original mapping, build coarse graph. -/// Returns false if no further coarsening is possible (convergence). -fn aggregation_phase( - state: &mut LouvainState, - level_comm: &mut Vec, -) -> bool { - // Renumber communities contiguously - let mut comm_remap: HashMap = HashMap::new(); - let mut next_id: usize = 0; - for &c in level_comm.iter() { - if !comm_remap.contains_key(&c) { - comm_remap.insert(c, next_id); - next_id += 1; - } - } - for c in level_comm.iter_mut() { - *c = comm_remap[c]; - } - let coarse_n = next_id; - - if coarse_n == state.cur_n { - return false; - } - - // Compose: update original_community through this level's assignments - for oc in state.original_community.iter_mut() { - *oc = level_comm[*oc]; - } - - // Build coarse graph for next level - let mut coarse_edge_map: HashMap<(usize, usize), f64> = HashMap::new(); - for (&(src, tgt), &w) in &state.cur_edges { - let cu = level_comm[src]; - let cv = level_comm[tgt]; - if cu == cv { - continue; - } - let key = if cu < cv { (cu, cv) } else { (cv, cu) }; - *coarse_edge_map.entry(key).or_insert(0.0) += w; - } - - let mut coarse_degree: Vec = vec![0.0; coarse_n]; - for (i, °) in state.cur_degree.iter().enumerate() { - coarse_degree[level_comm[i]] += deg; - } - - state.cur_n = coarse_n; - state.cur_edges = coarse_edge_map; - state.cur_degree = coarse_degree; - - true -} - -/// Compute final modularity score: Q = sum_c [ L_c / m - gamma * (k_c / 2m)^2 ] -fn compute_modularity( - edge_map: &HashMap<(usize, usize), f64>, - original_community: &[usize], - total_weight: f64, - resolution: f64, - n: usize, -) -> f64 { - let m = total_weight; - let m2 = 2.0 * m; - - let mut orig_degree: Vec = vec![0.0; n]; - for (&(src, tgt), &w) in edge_map { - orig_degree[src] += w; - orig_degree[tgt] += w; - } - - let max_comm = original_community.iter().copied().max().unwrap_or(0) + 1; - let mut kc: Vec = vec![0.0; max_comm]; - let mut lc: Vec = vec![0.0; max_comm]; - - for (i, °) in orig_degree.iter().enumerate() { - kc[original_community[i]] += deg; - } - for (&(src, tgt), &w) in edge_map { - if original_community[src] == original_community[tgt] { - lc[original_community[src]] += w; - } - } - - let mut modularity: f64 = 0.0; - for c in 0..max_comm { - if kc[c] > 0.0 { - modularity += lc[c] / m - resolution * (kc[c] / m2).powi(2); - } - } - modularity -} - -fn louvain_impl( - edges: &[GraphEdge], - node_ids: &[String], - resolution: f64, - seed: u32, -) -> LouvainResult { - let n = node_ids.len(); - let (edge_map, total_weight, mut state) = louvain_init(edges, node_ids, seed); - - if total_weight == 0.0 { - return LouvainResult { - assignments: node_ids - .iter() - .enumerate() - .map(|(i, id)| CommunityAssignment { - node: id.clone(), - community: i as i32, - }) - .collect(), - modularity: 0.0, - }; - } - - // m2 = 2 x total edge weight of the ORIGINAL graph -- a constant across all levels. - // Recalculating from cur_edges would undercount because coarsening strips intra-community - // edges, inflating the penalty term and causing under-merging at coarser levels. - let total_m2: f64 = 2.0 * total_weight; - - for _level in 0..LOUVAIN_MAX_LEVELS { - if state.cur_edges.is_empty() { - break; - } - - let (mut level_comm, any_moved) = local_move_phase(&mut state, resolution, total_m2); - if !any_moved { - break; - } - - if !aggregation_phase(&mut state, &mut level_comm) { - break; - } - } - - let modularity = compute_modularity(&edge_map, &state.original_community, total_weight, resolution, n); - - let assignments = node_ids - .iter() - .enumerate() - .map(|(i, id)| CommunityAssignment { - node: id.clone(), - community: state.original_community[i] as i32, - }) - .collect(); - - LouvainResult { - assignments, - modularity, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn edge(src: &str, tgt: &str) -> GraphEdge { - GraphEdge { - source: src.to_string(), - target: tgt.to_string(), - } - } - - #[test] - fn test_louvain_empty() { - let result = louvain_communities(vec![], vec![], None, None); - assert!(result.assignments.is_empty()); - assert_eq!(result.modularity, 0.0); - } - - #[test] - fn test_louvain_two_cliques() { - let edges = vec![ - edge("a", "b"), - edge("b", "c"), - edge("c", "a"), - edge("d", "e"), - edge("e", "f"), - edge("f", "d"), - ]; - let nodes: Vec = vec!["a", "b", "c", "d", "e", "f"] - .into_iter() - .map(String::from) - .collect(); - let result = louvain_communities(edges, nodes, None, None); - - let map: HashMap = result - .assignments - .into_iter() - .map(|a| (a.node, a.community)) - .collect(); - assert_eq!(map["a"], map["b"]); - assert_eq!(map["b"], map["c"]); - assert_eq!(map["d"], map["e"]); - assert_eq!(map["e"], map["f"]); - assert_ne!(map["a"], map["d"]); - assert!(result.modularity > 0.0); - } - - #[test] - fn test_louvain_single_component() { - let edges = vec![edge("a", "b"), edge("a", "c"), edge("b", "c")]; - let nodes: Vec = vec!["a", "b", "c"].into_iter().map(String::from).collect(); - let result = louvain_communities(edges, nodes, None, None); - let map: HashMap = result - .assignments - .into_iter() - .map(|a| (a.node, a.community)) - .collect(); - assert_eq!(map["a"], map["b"]); - assert_eq!(map["b"], map["c"]); - } -} diff --git a/crates/codegraph-core/src/graph/algorithms/mod.rs b/crates/codegraph-core/src/graph/algorithms/mod.rs index e2b87b481..9ceed17bc 100644 --- a/crates/codegraph-core/src/graph/algorithms/mod.rs +++ b/crates/codegraph-core/src/graph/algorithms/mod.rs @@ -2,13 +2,13 @@ pub mod bfs; pub mod centrality; -pub mod louvain; +pub mod leiden; pub mod shortest_path; pub mod tarjan; pub use bfs::{bfs_traversal, BfsEntry}; pub use centrality::{fan_in_out, FanInOutEntry}; -pub use louvain::{louvain_communities, CommunityAssignment, LouvainResult}; +pub use leiden::{leiden_communities, LeidenCommunitiesResult, LeidenCommunityAssignment}; pub use shortest_path::shortest_path; pub use tarjan::detect_cycles; diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index c45184650..b0e81b292 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -106,6 +106,83 @@ fn median(sorted: &[u32]) -> f64 { } } +/// Compute, per file, the set of symbol names that are `TYPE_DEF_KINDS`-kind +/// declarations (interface/type/struct/enum/trait/record). Mirrors JS +/// `computeTypeDefNamesByFile` in `graph/classifiers/roles.ts`. +fn compute_type_def_names_by_file( + rows: &[(i64, String, String, String, u32, u32)], +) -> HashMap> { + let mut by_file: HashMap> = HashMap::new(); + for (_id, name, kind, file, _fan_in, _fan_out) in rows { + if TYPE_DEF_KINDS.iter().any(|k| *k == kind.as_str()) { + by_file + .entry(file.clone()) + .or_default() + .insert(name.clone()); + } + } + by_file +} + +/// True when `name`/`kind` is a `method`/`property` member of an interface/type +/// declared in the same file — e.g. TS `interface Foo { bar: string }` extracts +/// `bar` as a top-level `method`-kind definition named `Foo.bar` (#1723). Every +/// language extractor qualifies interface/type members as `Owner.member` +/// (mirroring class method qualification), so the owner name is recovered from +/// the prefix before the first `.` and looked up against same-file +/// `TYPE_DEF_KINDS` declarations. Class methods use the identical `Owner.member` +/// convention but are unaffected here because `class` is not in `TYPE_DEF_KINDS` +/// — they remain subject to normal dead-code detection. +/// +/// These members can never gain inbound call edges by construction, so a +/// `fan_in == 0` reading carries zero dead-code signal for them, unlike a real +/// function/method where it does. Mirrors JS `isTypeDeclarationMember`. +fn is_type_declaration_member( + name: &str, + kind: &str, + file: &str, + type_def_names_by_file: &HashMap>, +) -> bool { + if kind != "method" && kind != "property" { + return false; + } + let Some(dot_idx) = name.find('.') else { + return false; + }; + let owner_name = &name[..dot_idx]; + type_def_names_by_file + .get(file) + .is_some_and(|names| names.contains(owner_name)) +} + +/// Filter raw `kind = 'property'` rows down to interface/type +/// property-signature members (#1809) — the only property rows that receive a +/// role (`leaf`). Property rows never reach `classify_rows` (they're excluded +/// from the `rows` query), so `is_type_declaration_member` must be applied to +/// them here explicitly — otherwise every property-kind interface member +/// would be misclassified instead of `leaf`, the same bug #1723 fixed for +/// `method`-kind members. +/// +/// Genuine (non-interface) class/struct/object fields are deliberately left +/// out of the returned ids — they get no role at all (`NULL`), the same +/// treatment `parameter` receives (#1723). A field's liveness is a question +/// of whether it's read/written anywhere in its owning class, which is a +/// dataflow question this crate has no property-access/write edge tracking +/// to answer, so "zero inbound `calls` edges" (guaranteed by construction) +/// carries zero dead-code signal for it (#1810). +fn filter_type_member_property_rows( + leaf_rows: Vec<(i64, String, String)>, + type_def_names_by_file: &HashMap>, +) -> Vec { + leaf_rows + .into_iter() + .filter(|(_, name, file)| { + is_type_declaration_member(name, "property", file, type_def_names_by_file) + }) + .map(|(id, _, _)| id) + .collect() +} + /// Dead sub-role classification matching JS `classifyDeadSubRole`. fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str { // Leaf kinds @@ -126,6 +203,7 @@ fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str { } /// Classify a single node into a role. +#[allow(clippy::too_many_arguments)] fn classify_node( name: &str, kind: &str, @@ -135,9 +213,16 @@ fn classify_node( is_exported: bool, production_fan_in: u32, has_active_file_siblings: bool, + is_type_member: bool, median_fan_in: f64, median_fan_out: f64, ) -> &'static str { + // Interface/type members (#1723) — never subject to call-graph dead-code + // detection, regardless of fan-in/fan-out/export status. + if is_type_member { + return "leaf"; + } + // Framework entry if FRAMEWORK_ENTRY_PREFIXES.iter().any(|p| name.starts_with(p)) { return "entry"; @@ -180,6 +265,17 @@ fn classify_node( // value references, not call sites, so no call edge is produced. We // require `fan_out > 0` as evidence that the function is non-trivial // (i.e. it calls something), ruling out truly inert dead helpers. + // + // NOTE (#1771): this used to also be the only thing rescuing functions + // referenced as object-literal property values (dispatch tables, e.g. + // `{ resolve: someFunction }`) — and only by coincidence, for whichever + // of those functions happened to have fan_out > 0 themselves. That + // pattern now gets a real `calls` edge (dynamic_kind "value-ref") at + // extraction time, so it no longer depends on this heuristic. Kept + // here as a fallback for value-reference shapes that still produce no + // edge at all — the logical-or default above, and others (ternary + // defaults, array-of-functions elements, default parameter values) + // that aren't extracted as edges yet. if kind == "function" && fan_out > 0 { return "leaf"; } @@ -188,7 +284,20 @@ fn classify_node( } if fan_in == 0 && is_exported { - return "entry"; + // Exported, zero fan-in. A genuine entry point (CLI command handler, + // exported API function called from outside the codebase, ESM loader + // hook, MCP tool handler, etc.) is always a function or method. Every + // other exported kind (interface/type/constant/class) is a live, + // intentional part of the public surface — but a data shape or config + // value, not something invoked from outside the codebase — so it's + // `leaf`: never `dead-*` (#1583) and never `entry` (#1780), regardless + // of whether the file has other active siblings. Mirrors JS + // `classifyNodeRole`. + return if kind == "function" || kind == "method" { + "entry" + } else { + "leaf" + }; } // Test-only: has callers but all are in test files @@ -267,11 +376,21 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result dead-leaf - let leaf_ids: Vec = { - let mut stmt = - tx.prepare("SELECT id FROM nodes WHERE kind IN ('parameter', 'property')")?; - let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; + // 1. Property kind (class/struct/object fields). Interface/type + // property-signature members (#1809) are filtered out below via + // `is_type_declaration_member` once `type_def_names_by_file` is available + // and classified `leaf`. Genuine (non-interface) fields get no role at + // all (#1810) — see `filter_type_member_property_rows`. + // `parameter` is deliberately NOT included here (#1723): a parameter's liveness + // is a local dataflow question, not a call-graph reachability question, so + // "no incoming call edges" carries zero dead-code signal for it. Parameters are + // also excluded from the main rows query below, so they never receive a role + // at all — the same treatment as `file`/`directory` nodes. + let leaf_rows: Vec<(i64, String, String)> = { + let mut stmt = tx.prepare("SELECT id, name, file FROM nodes WHERE kind = 'property'")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + })?; rows.filter_map(|r| r.ok()).collect() }; @@ -304,7 +423,7 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result rusqlite::Result rusqlite::Result rusqlite::Result> = HashMap::new(); - if !leaf_ids.is_empty() { - summary.dead += leaf_ids.len() as u32; - summary.dead_leaf += leaf_ids.len() as u32; - ids_by_role.insert("dead-leaf", leaf_ids); + let type_member_leaf_ids = filter_type_member_property_rows(leaf_rows, &type_def_names_by_file); + if !type_member_leaf_ids.is_empty() { + summary.leaf += type_member_leaf_ids.len() as u32; + ids_by_role + .entry("leaf") + .or_default() + .extend(type_member_leaf_ids); } classify_rows( @@ -423,6 +559,7 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result, prod_fan_in: &HashMap, active_files: &std::collections::HashSet, called_active_files: &std::collections::HashSet, + type_def_names_by_file: &HashMap>, median_fan_in: f64, median_fan_out: f64, ids_by_role: &mut HashMap<&'static str, Vec>, @@ -573,6 +712,7 @@ fn classify_rows( } else { false }; + let is_type_member = is_type_declaration_member(name, kind, file, type_def_names_by_file); let role = classify_node( name, kind, @@ -582,6 +722,7 @@ fn classify_rows( is_exported, prod_fi, has_active_siblings, + is_type_member, median_fan_in, median_fan_out, ); @@ -628,18 +769,22 @@ fn find_neighbour_files( Ok(result) } -/// Query leaf kind node IDs and callable node rows for a set of files. +/// Query leaf kind node rows and callable node rows for a set of files. +/// `parameter` is intentionally excluded from the leaf query (#1723) — see +/// `do_classify_full`'s leaf_rows comment for the rationale. Leaf rows carry +/// `name`/`file` (not just `id`) so callers can filter down to interface/type +/// property-signature members (#1809) via `filter_type_member_property_rows`. fn query_nodes_for_files( tx: &rusqlite::Transaction, files: &[&str], -) -> rusqlite::Result<(Vec, Vec<(i64, String, String, String, u32, u32)>)> { +) -> rusqlite::Result<(Vec<(i64, String, String)>, Vec<(i64, String, String, String, u32, u32)>)> { let ph: String = files.iter().map(|_| "?").collect::>().join(","); let leaf_sql = format!( - "SELECT id FROM nodes WHERE kind IN ('parameter', 'property') AND file IN ({})", + "SELECT id, name, file FROM nodes WHERE kind = 'property' AND file IN ({})", ph ); - let leaf_ids: Vec = { + let leaf_rows: Vec<(i64, String, String)> = { let mut stmt = tx.prepare(&leaf_sql)?; for (i, f) in files.iter().enumerate() { stmt.raw_bind_parameter(i + 1, *f)?; @@ -647,7 +792,7 @@ fn query_nodes_for_files( let mut rows = stmt.raw_query(); let mut result = Vec::new(); while let Some(row) = rows.next()? { - result.push(row.get::<_, i64>(0)?); + result.push((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)); } result }; @@ -681,7 +826,7 @@ fn query_nodes_for_files( result }; - Ok((leaf_ids, rows)) + Ok((leaf_rows, rows)) } // ── Incremental classification ─────────────────────────────────────── @@ -707,9 +852,9 @@ pub(crate) fn do_classify_incremental( let (median_fan_in, median_fan_out) = compute_global_medians(&tx)?; - let (leaf_ids, rows) = query_nodes_for_files(&tx, &all_affected)?; + let (leaf_rows, rows) = query_nodes_for_files(&tx, &all_affected)?; - if rows.is_empty() && leaf_ids.is_empty() { + if rows.is_empty() && leaf_rows.is_empty() { tx.commit()?; return Ok(summary); } @@ -729,6 +874,7 @@ pub(crate) fn do_classify_incremental( // from production-reachable barrels (traces through multi-level chains) (#837). // Same recursive CTE logic as the full-classify path (step 3b), but scoped // to affected files only via the additional `AND n.file IN (...)` filter. + // `method` is excluded (#1780) — see the full-classify path for rationale. { let reexport_sql = format!( "WITH RECURSIVE prod_reachable(file_id) AS ( @@ -752,7 +898,7 @@ pub(crate) fn do_classify_incremental( WHERE e.kind = 'reexports' AND e.source_id IN (SELECT file_id FROM prod_reachable) ) - AND n.kind NOT IN ('file', 'directory', 'parameter', 'property') + AND n.kind NOT IN ('file', 'directory', 'parameter', 'property', 'method') AND n.file IN ({})", test_file_filter_col("src.file"), affected_ph @@ -804,12 +950,21 @@ pub(crate) fn do_classify_incremental( let (active_files, called_active_files) = compute_active_files(&rows); + // Compute interface/type owner names per file (#1723) — see do_classify_full. + let type_def_names_by_file = compute_type_def_names_by_file(&rows); + let mut ids_by_role: HashMap<&str, Vec> = HashMap::new(); - if !leaf_ids.is_empty() { - summary.dead += leaf_ids.len() as u32; - summary.dead_leaf += leaf_ids.len() as u32; - ids_by_role.insert("dead-leaf", leaf_ids); + // Filter property rows: interface/type members (#1809) -> leaf; genuine + // class/struct/object fields get no role at all (#1810). See + // do_classify_full/`filter_type_member_property_rows`. + let type_member_leaf_ids = filter_type_member_property_rows(leaf_rows, &type_def_names_by_file); + if !type_member_leaf_ids.is_empty() { + summary.leaf += type_member_leaf_ids.len() as u32; + ids_by_role + .entry("leaf") + .or_default() + .extend(type_member_leaf_ids); } classify_rows( @@ -818,6 +973,7 @@ pub(crate) fn do_classify_incremental( &prod_fan_in, &active_files, &called_active_files, + &type_def_names_by_file, median_fan_in, median_fan_out, &mut ids_by_role, diff --git a/crates/codegraph-core/src/infrastructure/config.rs b/crates/codegraph-core/src/infrastructure/config.rs index 4dbb706c2..f2a37a3d4 100644 --- a/crates/codegraph-core/src/infrastructure/config.rs +++ b/crates/codegraph-core/src/infrastructure/config.rs @@ -32,6 +32,10 @@ pub struct BuildConfig { /// Config-level path aliases (merged with tsconfig aliases). #[serde(default)] pub aliases: std::collections::HashMap, + + /// Analysis-tuning settings (points-to solver, etc.). + #[serde(default)] + pub analysis: AnalysisConfig, } #[derive(Debug, Clone, Deserialize)] @@ -44,6 +48,16 @@ pub struct BuildSettings { /// Drift detection threshold for incremental builds. #[serde(default = "default_drift_threshold")] pub drift_threshold: f64, + + /// Max size of a same-(name, kind) sibling group + /// `reconnect_reverse_dep_edges` will run its line-alignment against + /// (#1865). Building the shift histogram is O(n*m) in the old/new group + /// sizes; groups above this size fall back to nearest-line matching to + /// bound worst-case incremental-build cost. Mirrors + /// `DEFAULTS.build.reverseDepAlignmentMaxGroupSize` in + /// `src/infrastructure/config.ts`. + #[serde(default = "default_reverse_dep_alignment_max_group_size")] + pub reverse_dep_alignment_max_group_size: usize, } // Manual impl so `BuildSettings::default()` matches the serde field defaults. @@ -54,6 +68,7 @@ impl Default for BuildSettings { Self { incremental: default_true(), drift_threshold: default_drift_threshold(), + reverse_dep_alignment_max_group_size: default_reverse_dep_alignment_max_group_size(), } } } @@ -66,6 +81,35 @@ fn default_drift_threshold() -> f64 { 0.1 } +fn default_reverse_dep_alignment_max_group_size() -> usize { + 200 +} + +/// Subset of `CodegraphConfig.analysis` relevant to the build pipeline. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisConfig { + /// Maximum fixed-point iterations for the Phase 8.3 points-to solver. + /// Mirrors `DEFAULTS.analysis.pointsToMaxIterations` in + /// `src/infrastructure/config.ts` and `MAX_SOLVER_ITERATIONS` in + /// `src/domain/graph/builder/stages/build_edges.rs`. Threaded to + /// `build_call_edges()` via `build_and_insert_call_edges()`. + #[serde(default = "default_points_to_max_iterations")] + pub points_to_max_iterations: u32, +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + points_to_max_iterations: default_points_to_max_iterations(), + } + } +} + +fn default_points_to_max_iterations() -> u32 { + 50 +} + /// Build options passed from the JS caller. #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -143,6 +187,17 @@ mod tests { assert!(config.include.is_empty()); assert!(config.exclude.is_empty()); assert!(config.build.incremental); + // Default mirrors DEFAULTS.analysis.pointsToMaxIterations in config.ts. + assert_eq!(config.analysis.points_to_max_iterations, 50); + // Default mirrors DEFAULTS.build.reverseDepAlignmentMaxGroupSize (#1865). + assert_eq!(config.build.reverse_dep_alignment_max_group_size, 200); + } + + #[test] + fn deserialize_reverse_dep_alignment_max_group_size_override() { + let json = r#"{"build": {"reverseDepAlignmentMaxGroupSize": 32}}"#; + let config: BuildConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.build.reverse_dep_alignment_max_group_size, 32); } #[test] @@ -166,6 +221,18 @@ mod tests { assert!(!config.build.incremental); assert_eq!(config.build.drift_threshold, 0.2); assert_eq!(config.aliases.get("@/").unwrap(), "src/"); + // analysis key omitted entirely — must still fall back to the default. + assert_eq!(config.analysis.points_to_max_iterations, 50); + } + + #[test] + fn deserialize_analysis_override() { + // Mirrors the shape the JS side serializes via JSON.stringify(ctx.config) + // when a repo sets a non-default `analysis.pointsToMaxIterations` in + // .codegraphrc.json (issue #1753). + let json = r#"{"analysis": {"pointsToMaxIterations": 5}}"#; + let config: BuildConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.analysis.points_to_max_iterations, 5); } #[test] diff --git a/crates/codegraph-core/src/lib.rs b/crates/codegraph-core/src/lib.rs index a4e7a3a29..bfea5f561 100644 --- a/crates/codegraph-core/src/lib.rs +++ b/crates/codegraph-core/src/lib.rs @@ -92,27 +92,47 @@ pub fn parse_files_full( } /// Resolve a single import path. +/// +/// `workspaces` carries the caller's already-detected monorepo workspace +/// packages (see `detectWorkspaces()`/`setWorkspaces()` in +/// `src/infrastructure/config.ts` — the native engine has no workspace +/// *detection* of its own, only resolution against a supplied map; issue #1927). #[napi] pub fn resolve_import( from_file: String, import_source: String, root_dir: String, aliases: Option, + workspaces: Option>, ) -> String { let aliases = aliases.unwrap_or(PathAliases { base_url: None, paths: vec![], }); - domain::graph::resolve::resolve_import_path(&from_file, &import_source, &root_dir, &aliases) + let workspace_map = workspaces.map(|w| domain::graph::resolve::workspaces_from_packages(&w)); + domain::graph::resolve::resolve_import_path( + &from_file, + &import_source, + &root_dir, + &aliases, + workspace_map.as_ref(), + ) } /// Batch resolve multiple imports. +/// +/// Resets the process-lifetime workspace-resolved-paths cache (read by +/// `compute_confidence()`) before resolving — this is the once-per-build +/// entry point on the per-call FFI path (called exactly once per build by +/// `resolveImportsBatch()` in resolve.ts); see +/// `reset_workspace_resolved_paths()`'s doc comment for the full contract. #[napi] pub fn resolve_imports( inputs: Vec, root_dir: String, aliases: Option, known_files: Option>, + workspaces: Option>, ) -> Vec { let aliases = aliases.unwrap_or(PathAliases { base_url: None, @@ -120,7 +140,15 @@ pub fn resolve_imports( }); let known_set = known_files.map(|v| v.into_iter().collect::>()); - domain::graph::resolve::resolve_imports_batch(&inputs, &root_dir, &aliases, known_set.as_ref()) + let workspace_map = workspaces.map(|w| domain::graph::resolve::workspaces_from_packages(&w)); + domain::graph::resolve::reset_workspace_resolved_paths(); + domain::graph::resolve::resolve_imports_batch( + &inputs, + &root_dir, + &aliases, + known_set.as_ref(), + workspace_map.as_ref(), + ) } /// Compute proximity-based confidence for call resolution. diff --git a/crates/codegraph-core/src/shared/constants.rs b/crates/codegraph-core/src/shared/constants.rs index 9aa28a128..0ec53a224 100644 --- a/crates/codegraph-core/src/shared/constants.rs +++ b/crates/codegraph-core/src/shared/constants.rs @@ -2,16 +2,37 @@ /// on deeply nested trees. Used by extractors, complexity, CFG, and dataflow. pub const MAX_WALK_DEPTH: usize = 200; -// ─── Louvain community detection ──────────────────────────────────── +// ─── Leiden community detection ───────────────────────────────────── +// +// Mirrors the TS reference implementation's defaults exactly (both must +// produce identical output — see leiden.rs's module doc for why): +// - DEFAULT_MAX_LEVELS/DEFAULT_MAX_LOCAL_PASSES/GAIN_EPSILON: +// src/graph/algorithms/leiden/optimiser.ts +// - DEFAULT_REFINEMENT_THETA/DEFAULT_RESOLUTION: +// optimiser.ts's normalizeOptions() and src/infrastructure/config.ts's +// DEFAULTS.community +// - DEFAULT_CAPACITY_GROWTH_FACTOR: src/graph/algorithms/leiden/partition.ts -/// Maximum number of coarsening levels in the Louvain algorithm. -pub const LOUVAIN_MAX_LEVELS: usize = 50; +/// Maximum number of coarsening levels. +pub const LEIDEN_DEFAULT_MAX_LEVELS: usize = 50; /// Maximum number of local-move passes per level before stopping. -pub const LOUVAIN_MAX_PASSES: usize = 20; +pub const LEIDEN_DEFAULT_MAX_LOCAL_PASSES: usize = 20; -/// Minimum modularity gain to accept a node move (avoids floating-point noise). -pub const LOUVAIN_MIN_GAIN: f64 = 1e-12; +/// Minimum quality gain to accept a node move (avoids floating-point noise). +pub const LEIDEN_GAIN_EPSILON: f64 = 1e-12; + +/// Default Boltzmann temperature for the refinement phase's probabilistic +/// candidate selection (Algorithm 3, Traag et al. 2019). +pub const LEIDEN_DEFAULT_REFINEMENT_THETA: f64 = 1.0; + +/// Default resolution (gamma) parameter for modularity optimization. +pub const LEIDEN_DEFAULT_RESOLUTION: f64 = 1.0; + +/// Growth multiplier applied when a partition's per-community arrays need to +/// grow beyond their initial capacity (post-refinement disconnected-community +/// splitting can mint more community ids than the initial allocation). +pub const LEIDEN_DEFAULT_CAPACITY_GROWTH_FACTOR: f64 = 1.5; /// Default random seed for deterministic community detection. pub const DEFAULT_RANDOM_SEED: u32 = 42; @@ -29,3 +50,31 @@ pub const FAST_PATH_MAX_CHANGED_FILES: usize = 5; /// Minimum existing file count required before the fast path is considered. /// Typed as `i64` to match `get_existing_file_count()` return type (SQLite row count). pub const FAST_PATH_MIN_EXISTING_FILES: i64 = 20; + +// ─── Import edge classification ───────────────────────────────────── +// +// Mirrors TS's `shared/kinds.ts` (TYPE_ERASED_SYMBOL_KINDS, +// isTypeErasedImportTarget) and `domain/parser.ts` (TYPESCRIPT_EXTENSIONS). + +/// TypeScript source extensions — type annotations (and TS's compile-time-only +/// 'interface'/'type' declarations) only exist for these. +pub const TYPESCRIPT_EXTENSIONS: [&str; 2] = [".ts", ".tsx"]; + +/// Symbol kinds that are compile-time-only in TypeScript — interfaces and +/// type aliases are erased before runtime, so a symbol of one of these kinds +/// can never receive a `calls` edge. Importing one — with or without the +/// `type` keyword — is the only consumption signal `codegraph exports` can +/// observe for these kinds (#1833). +pub const TYPE_ERASED_SYMBOL_KINDS: [&str; 2] = ["interface", "type"]; + +/// True when a named import specifier resolving to `kind` in `file` can only +/// ever be consumed as a type — see TYPE_ERASED_SYMBOL_KINDS. Scoped to +/// `.ts`/`.tsx` files because other languages reuse the 'interface'/'type' +/// node kinds for constructs that *are* runtime-observable (e.g. a Go `type` +/// alias, a Java `interface` dispatched through at runtime) — crediting +/// those on mere import would mask genuinely dead code instead of fixing a +/// false positive. +pub fn is_type_erased_import_target(kind: &str, file: &str) -> bool { + TYPE_ERASED_SYMBOL_KINDS.contains(&kind) + && TYPESCRIPT_EXTENSIONS.iter().any(|ext| file.ends_with(ext)) +} diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index a1c3ccdc2..fea6d4fb6 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -99,6 +99,14 @@ pub struct Definition { pub cfg: Option, #[napi(ts_type = "Definition[] | undefined")] pub children: Option>, + /// Set when the underlying AST node structurally has no executable body + /// (an interface/protocol/trait method signature, an abstract method with + /// no block, a Rust trait `function_signature_item`, etc). Mirrors the TS + /// `Definition.bodyless` signal — see `hasFuncBody` in + /// `src/ast-analysis/apply-results.ts` (issue #1922). A dotted name alone + /// does not imply this: it's the normal qualified name for real bodied + /// class/struct/impl/module methods across every extractor. + pub bodyless: Option, } #[napi(object)] @@ -114,6 +122,16 @@ pub struct Call { pub key_expr: Option, } +/// `import { X as Y }`: the local binding name (Y) paired with the original +/// name exported by the source module (X). Mirrors TS `Import.renamedImports`. +/// See `Import.renamed_imports` for why this is needed (#1730). +#[napi(object)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenamedImport { + pub local: String, + pub imported: String, +} + #[napi(object)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Import { @@ -125,6 +143,26 @@ pub struct Import { pub reexport: Option, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: Option, + /// For `import { X as Y }` specifiers: the local binding name (Y) mapped to + /// the original name exported by the source module (X). `names` always + /// carries the local (post-rename) binding — this field lets call-edge + /// resolution recover the *original* symbol name to look up in the + /// imported file when a call site uses the local alias (#1730). Only + /// populated for specifiers that actually rename a binding. Mirrors TS + /// `Import.renamedImports`. + #[napi(js_name = "renamedImports")] + pub renamed_imports: Option>, + /// Local binding names (post-alias, matching entries in `names`) that + /// carry an inline per-specifier `type`/`typeof` modifier + /// (`import { type X }`), as distinct from a whole-statement + /// `import type { X }` (already covered by `type_only`). Only populated + /// for specifiers that actually use the modifier — mirrors + /// `renamed_imports`'s sparse-population convention. Lets a mixed + /// statement (`import { value, type Foo }`) still credit `Foo` with a + /// symbol-level `imports-type` edge. Mirrors TS `Import.typeOnlyNames` + /// (#1813). + #[napi(js_name = "typeOnlyNames")] + pub type_only_names: Option>, // Language-specific flags #[napi(js_name = "pythonImport")] pub python_import: Option, @@ -168,6 +206,8 @@ impl Import { type_only: None, reexport: None, wildcard_reexport: None, + renamed_imports: None, + type_only_names: None, python_import: None, go_import: None, rust_use: None, @@ -544,6 +584,32 @@ pub struct AliasMapping { pub targets: Vec, } +/// A single monorepo workspace package, mirroring `WorkspaceEntry` in +/// `src/infrastructure/config.ts`. `entry` is `None` when no resolvable +/// entry point was found for the package (missing `main`/`source`/index +/// file). Serves double duty: passed as a napi array argument to +/// `resolve_import`/`resolve_imports` for the per-call FFI path, and +/// deserialized from the `workspaces_json` blob `NativeDatabase::build_graph` +/// receives for the full Rust orchestrator path (both use the same +/// `{ packageName, dir, entry }` JSON shape). +/// +/// `#[serde(rename_all = "camelCase")]` is required here even though +/// `#[napi(object)]` already camelCases fields for direct FFI calls — that +/// conversion is a separate mechanism from `serde_json`, which only sees the +/// literal Rust field names unless told otherwise. Without it, +/// `serde_json::from_str` on `workspaces_json` (camelCase, produced by +/// `JSON.stringify(getWorkspacesForNative(...))`) fails with "missing field +/// `package_name`" (mirrors `BuildPathAliases`'s reason for existing +/// alongside `PathAliases` in infrastructure/config.rs). +#[napi(object)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspacePackage { + pub package_name: String, + pub dir: String, + pub entry: Option, +} + #[napi(object)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportResolutionInput { diff --git a/docs/architecture/decisions/002-dynamic-call-resolution.md b/docs/architecture/decisions/002-dynamic-call-resolution.md index 0e70c3422..b2ed23ccb 100644 --- a/docs/architecture/decisions/002-dynamic-call-resolution.md +++ b/docs/architecture/decisions/002-dynamic-call-resolution.md @@ -76,11 +76,45 @@ export type DynamicKind = | 'computed-key' // obj[k]() — resolvable iff k is a const literal, else flag | 'reflection' // .call/.apply/.bind, getattr, Method.invoke, $obj->$m() | 'eval' // eval(), new Function() — undecidable - | 'unresolved-dynamic'; // detected dynamic call we cannot resolve + | 'unresolved-dynamic' // detected dynamic call we cannot resolve + | 'value-ref'; // bare identifier used as a value reference, not a call site — + // object-literal property value (dispatch tables, e.g. + // `{ resolve: someFn }`, #1771), assignment to a Lua + // global/builtin identifier (e.g. `require = tracedRequire`, + // #1776), or the right operand of an `instanceof` check + // (e.g. `err instanceof CodegraphError`, #1784) — + // resolvable against function/method-kind targets, plus + // class-kind for the instanceof site ``` `dynamic?: boolean` is kept to avoid churning every `call.dynamic ? 1 : 0` site. +`value-ref` is Track A (resolvable) but deliberately **not** added to the flag-only +sink-edge set: when the identifier doesn't resolve to a function/method/class (e.g. a +plain data reference like `{ name: SOME_CONSTANT }`), that's the common case, not +an undecidable dynamic call site — so it's silently dropped rather than flagged, +unlike `eval`/`computed-key`/`unresolved-dynamic`. + +`value-ref` is deliberately syntax-position-agnostic: any bare identifier that +names a known function/method/class and appears somewhere other than a call +site qualifies, regardless of which language or grammar shape produced it. +Object-literal property values (#1771), Lua assignment to a global/builtin +identifier (#1776, `require = tracedRequire` — a builtin name isn't a +locally-scoped variable that alias/points-to resolution could ever observe, +so this is the narrow, language-specific case where a plain reference edge +is the correct substitute for real alias tracking), and the right operand of +an `instanceof` check (#1784, `err instanceof CodegraphError` — `instanceof` +evaluates its right operand as a value, never calls it) are three independent +extraction sites feeding the same resolution/filtering logic downstream. The +`instanceof` site is the first to resolve against `class`-kind targets (the +other two are function/method only, since `instanceof`'s operand is always a +class/constructor) — the resolver-side filter is keyed on `dynamicKind`, not +on which site produced the call, so this is a per-kind allowed-target-kind +set rather than a per-site one. New languages/positions can add a fourth +without touching `build-edges.ts` / `incremental.ts` / `build_edges.rs` +again, beyond widening the allowed-target-kind set if the new site's operand +isn't a function/method/class. + ### Sink edges reuse `kind='calls'`, not a new edge kind A new `EdgeKind` would ripple through every edge-kind switch, role classifier, exporter, MCP tool, and the viewer — high blast radius. Instead: DB migration adds `dynamic_kind TEXT` column to `edges`; sink edges use `kind='calls'`, `dynamic=1`, `dynamic_kind=`, `confidence=0.0`. Confidence below `DEFAULT_MIN_CONFIDENCE=0.5` means they never pollute normal queries or exports but remain queryable when explicitly requested. diff --git a/docs/examples/CLI.md b/docs/examples/CLI.md index eebe28fc2..a03a51051 100644 --- a/docs/examples/CLI.md +++ b/docs/examples/CLI.md @@ -370,21 +370,23 @@ Impact analysis for files matching "src/parser.js": ## diff-impact — Impact of git changes +Compares the working tree, staged changes, or a git ref against the current graph and reports which functions were edited and how far their impact reaches. + ```bash -codegraph diff-impact main -T +codegraph diff-impact --staged -T ``` ``` - Changed files: - M src/structure.js (structureCmd, computeStructure) - M src/queries.js (findNodeByName) +diff-impact: 2 files changed + + 2 functions changed: - Impacted functions: - -- Level 1: - ^ resolveNoTests src/cli.js:59 - ^ structureTool src/mcp.js:312 + f resolveNoTests -- src/cli/shared/options.ts:39 + ^ 1 transitive callers + f renderNoCallEdgesFallback -- src/presentation/call-ref-sections.ts:70 + ^ 6 transitive callers - Total: 2 functions affected by this diff + Summary: 2 functions changed -> 7 callers affected across 4 files ``` Also available as a Mermaid diagram (`-f mermaid`) for visual impact graphs. @@ -492,6 +494,14 @@ Found 1 file-level cycle: src/parser.js -> src/constants.js -> src/parser.js ``` +Cycles whose only closing edge is a low-confidence dynamic call (resolved via +a resolver heuristic rather than confirmed statically) are marked +`[speculative]` rather than reported as confirmed structural cycles. Pass +`--exclude-speculative` to drop them from the output entirely, or `--json` to +get a `speculative: boolean` field per cycle. `codegraph check --cycles` and +the manifesto `noCycles` rule ignore speculative-only cycles by default (see +`check.excludeSpeculativeCycles` in [configuration](../guides/configuration.md)). + --- ## export — Graph as DOT, Mermaid, JSON, GraphML, GraphSON, or Neo4j CSV @@ -1051,32 +1061,60 @@ codegraph check -T Combines structural summary + fn-impact + complexity metrics into one structured report per function. Use `--quick` for structural summary only (skip impact and health metrics). ```bash -codegraph audit src/builder.js -T +codegraph audit diffImpactData -T -f src/domain/analysis/diff-impact.ts ``` ``` -# Audit: src/builder.js +# Audit: diffImpactData (function) + 1 function(s) analyzed -## buildGraph (function) — src/builder.js:335 - Parameters: (rootDir, opts = {}) - Complexity: cognitive=495 cyclomatic=185 nesting=9 MI=0 ⚠ - Impact: 1 transitive callers - ^ resolveNoTests src/cli.js:59 - Calls: openDb, initSchema, loadConfig, collectFiles, getChangedFiles, ... +## f diffImpactData (function) [utility] + src/domain/analysis/diff-impact.ts:259-389 (131 lines) + Compute diff-impact analysis between two git refs (or staged changes). + + Health: + Cognitive: 11 Cyclomatic: 13 Nesting: 1 + MI: 40.2 + Halstead: vol=2989.57 diff=36.62 effort=109477.32 bugs=0.9965 + LOC: 131 SLOC: 111 Comments: 8 + + Threshold Breaches: + [WARN] cyclomatic: 13 >= 10 + + Impact: 3 transitive dependent(s) + -- Level 1 (2 functions): + ^ f diffImpactMermaid src/presentation/diff-impact-mermaid.ts:127 + ^ f diffImpact src/presentation/queries-cli/impact.ts:298 + ---- Level 2 (1 functions): + ^ o execute src/cli/commands/diff-impact.ts:20 + + Calls (12): + f loadConfig src/infrastructure/config.ts:771 + f openReadonlyOrFail src/db/connection.ts:376 + f findDbPath src/db/connection.ts:358 + f findGitRoot src/domain/analysis/diff-impact.ts:24 + f runGitDiff src/domain/analysis/diff-impact.ts:41 + f parseGitDiff src/domain/analysis/diff-impact.ts:65 + f findAffectedFunctions src/domain/analysis/diff-impact.ts:102 + f checkBoundaryViolations src/domain/analysis/diff-impact.ts:227 + f paginateResult src/shared/paginate.ts:80 + f buildFunctionImpactResults src/domain/analysis/diff-impact.ts:133 + f lookupCoChanges src/domain/analysis/diff-impact.ts:176 + f lookupOwnership src/domain/analysis/diff-impact.ts:201 -## collectFiles (function) — src/builder.js:45 - Complexity: cognitive=12 cyclomatic=8 nesting=3 MI=45 - Impact: 1 transitive callers - ^ buildGraph src/builder.js:335 - Calls: isTestFile, normalizePath + Called by (2): + f diffImpactMermaid src/presentation/diff-impact-mermaid.ts:127 + f diffImpact src/presentation/queries-cli/impact.ts:298 - ... (8 more functions) + Tests (2 files): + tests/unit/queries-unit.test.ts + tests/integration/queries.test.ts ``` -Single function: +Audit an entire file — same per-function block repeated once for every function/method in the file: ```bash -codegraph audit buildGraph -T +codegraph audit src/domain/analysis/diff-impact.ts -T ``` --- diff --git a/docs/examples/MCP.md b/docs/examples/MCP.md index 318433a43..05bb103f0 100644 --- a/docs/examples/MCP.md +++ b/docs/examples/MCP.md @@ -252,46 +252,92 @@ Impact analysis for files matching "src/parser.js": ```json { "tool": "diff_impact", - "arguments": { "staged": true, "no_tests": true } + "arguments": { "staged": true, "no_tests": true, "depth": 1 } } ``` ```json { - "changedFiles": ["src/structure.js", "src/queries.js"], - "changedFunctions": [ - { "name": "structureCmd", "file": "src/structure.js", "line": 42 }, - { "name": "findNodeByName", "file": "src/queries.js", "line": 180 } + "changedFiles": 2, + "newFiles": [], + "affectedFunctions": [ + { + "name": "resolveNoTests", + "kind": "function", + "file": "src/cli/shared/options.ts", + "line": 39, + "transitiveCallers": 1, + "levels": { + "1": [ + { "name": "resolveQueryOpts", "kind": "function", "file": "src/cli/shared/options.ts", "line": 51 } + ] + }, + "edges": [ + { "from": "src/cli/shared/options.ts::resolveNoTests:39", "to": "src/cli/shared/options.ts::resolveQueryOpts:51" } + ] + }, + { + "name": "renderNoCallEdgesFallback", + "kind": "function", + "file": "src/presentation/call-ref-sections.ts", + "line": 70, + "transitiveCallers": 2, + "levels": { + "1": [ + { "name": "renderAuditFunction", "kind": "function", "file": "src/presentation/audit.ts", "line": 77 }, + { "name": "renderExplainEdges", "kind": "function", "file": "src/presentation/queries-cli/inspect.ts", "line": 484 } + ] + }, + "edges": [ + { "from": "src/presentation/call-ref-sections.ts::renderNoCallEdgesFallback:70", "to": "src/presentation/audit.ts::renderAuditFunction:77" }, + { "from": "src/presentation/call-ref-sections.ts::renderNoCallEdgesFallback:70", "to": "src/presentation/queries-cli/inspect.ts::renderExplainEdges:484" } + ] + } ], - "impacted": [ - { "name": "resolveNoTests", "file": "src/cli.js", "level": 1 }, - { "name": "structureTool", "file": "src/mcp.js", "level": 1 } - ], - "totalImpacted": 2 + "affectedFiles": ["src/cli/shared/options.ts", "src/presentation/audit.ts", "src/presentation/queries-cli/inspect.ts"], + "historicallyCoupled": [], + "ownership": null, + "boundaryViolations": [], + "boundaryViolationCount": 0, + "summary": { + "functionsChanged": 2, + "callersAffected": 3, + "filesAffected": 3, + "historicallyCoupledCount": 0, + "ownersAffected": 0, + "boundaryViolationCount": 0 + } } ``` -With `format: "mermaid"`, returns a flowchart for visual rendering: +With `format: "mermaid"`, returns a flowchart for visual rendering — each changed file becomes a subgraph, and callers not directly reachable from a changed function are grouped into a "blast radius" subgraph: ```json { "tool": "diff_impact", - "arguments": { "ref": "main", "no_tests": true, "format": "mermaid" } + "arguments": { "staged": true, "no_tests": true, "format": "mermaid", "depth": 1 } } ``` ```mermaid flowchart TB - subgraph Changed - structureCmd["structureCmd\nsrc/structure.js:42"] - findNodeByName["findNodeByName\nsrc/queries.js:180"] + subgraph sg0["src/cli/shared/options.ts **(modified)**"] + n0["resolveNoTests"] + end + style sg0 fill:#fff3e0,stroke:#ff9800 + subgraph sg1["src/presentation/call-ref-sections.ts **(modified)**"] + n2["renderNoCallEdgesFallback"] end - subgraph Impacted - resolveNoTests["resolveNoTests\nsrc/cli.js:59"] - structureTool["structureTool\nsrc/mcp.js:312"] + style sg1 fill:#fff3e0,stroke:#ff9800 + subgraph sg2["Callers **(blast radius)**"] + n1["resolveQueryOpts"] + n3["renderAuditFunction"] + n4["renderExplainEdges"] end - structureCmd --> resolveNoTests - findNodeByName --> structureTool + style sg2 fill:#f3e5f5,stroke:#9c27b0 + n0 --> n1 + n2 --> n3 + n2 --> n4 ``` --- @@ -753,30 +799,91 @@ Function-level community detection: ## audit — Composite risk report -Combines explain + impact + complexity metrics in one call — gives an agent everything it needs to assess a function. +Combines explain + impact + complexity metrics in one call — gives an agent everything it needs to assess a function. Unlike the CLI, the MCP tool returns the raw `{ target, kind, functions: [...] }` JSON payload rather than rendered text. ```json { "tool": "audit", - "arguments": { "target": "buildGraph", "no_tests": true } + "arguments": { "target": "diffImpactData", "file": "src/domain/analysis/diff-impact.ts", "no_tests": true } } ``` -``` -# Audit: buildGraph (function) — src/builder.js:335 - Parameters: (rootDir, opts = {}) - Complexity: cognitive=495 cyclomatic=185 nesting=9 MI=0 ⚠ - Impact: 1 transitive callers - ^ resolveNoTests src/cli.js:59 - Calls: openDb, initSchema, loadConfig, collectFiles, getChangedFiles, ... +```json +{ + "target": "diffImpactData", + "kind": "function", + "functions": [ + { + "name": "diffImpactData", + "kind": "function", + "file": "src/domain/analysis/diff-impact.ts", + "line": 259, + "endLine": 389, + "role": "utility", + "lineCount": 131, + "summary": "Compute diff-impact analysis between two git refs (or staged changes).", + "signature": null, + "callees": [ + { "name": "loadConfig", "kind": "function", "file": "src/infrastructure/config.ts", "line": 771 }, + { "name": "openReadonlyOrFail", "kind": "function", "file": "src/db/connection.ts", "line": 376 }, + { "name": "findDbPath", "kind": "function", "file": "src/db/connection.ts", "line": 358 }, + { "name": "findGitRoot", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 24 }, + { "name": "runGitDiff", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 41 }, + { "name": "parseGitDiff", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 65 }, + { "name": "findAffectedFunctions", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 102 }, + { "name": "checkBoundaryViolations", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 227 }, + { "name": "paginateResult", "kind": "function", "file": "src/shared/paginate.ts", "line": 80 }, + { "name": "buildFunctionImpactResults", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 133 }, + { "name": "lookupCoChanges", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 176 }, + { "name": "lookupOwnership", "kind": "function", "file": "src/domain/analysis/diff-impact.ts", "line": 201 } + ], + "callers": [ + { "name": "diffImpactMermaid", "kind": "function", "file": "src/presentation/diff-impact-mermaid.ts", "line": 127 }, + { "name": "diffImpact", "kind": "function", "file": "src/presentation/queries-cli/impact.ts", "line": 298 } + ], + "relatedTests": [ + { "file": "tests/unit/queries-unit.test.ts" }, + { "file": "tests/integration/queries.test.ts" } + ], + "impact": { + "totalDependents": 3, + "levels": { + "1": [ + { "name": "diffImpactMermaid", "kind": "function", "file": "src/presentation/diff-impact-mermaid.ts", "line": 127 }, + { "name": "diffImpact", "kind": "function", "file": "src/presentation/queries-cli/impact.ts", "line": 298 } + ], + "2": [ + { "name": "execute", "kind": "method", "file": "src/cli/commands/diff-impact.ts", "line": 20 } + ] + } + }, + "health": { + "cognitive": 11, + "cyclomatic": 13, + "maxNesting": 1, + "maintainabilityIndex": 40.2, + "halstead": { "volume": 2989.57, "difficulty": 36.62, "effort": 109477.32, "bugs": 0.9965 }, + "loc": 131, + "sloc": 111, + "commentLines": 8, + "thresholdBreaches": [ + { "metric": "cyclomatic", "value": 13, "threshold": 10, "level": "warn" } + ] + }, + "riskScore": null, + "complexityNotes": null, + "sideEffects": null + } + ] +} ``` -Audit an entire file: +Audit an entire file — `functions` contains one entry per function/method in the file, each with the same shape: ```json { "tool": "audit", - "arguments": { "target": "src/builder.js", "no_tests": true } + "arguments": { "target": "src/domain/analysis/diff-impact.ts", "no_tests": true } } ``` @@ -949,6 +1056,8 @@ With Mermaid output: Show exported symbols of a file with per-symbol consumers — who calls each export and from where. +Each entry in a symbol's `consumers[]` array carries a `consumerKind` field: `"symbol"` for a real caller/constructor (`name`/`line` are a genuine call-site), or `"file"` for a whole-file reference such as `import type { X }`, where there is no specific calling symbol — `name` equals `file` and `line` is always `0`. + ```json { "tool": "file_exports", diff --git a/docs/examples/claude-code-hooks/track-edits.sh b/docs/examples/claude-code-hooks/track-edits.sh index 758db1328..f73652e8f 100644 --- a/docs/examples/claude-code-hooks/track-edits.sh +++ b/docs/examples/claude-code-hooks/track-edits.sh @@ -23,8 +23,27 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Use git worktree root so each worktree session has its own edit log -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +# Resolve the git worktree that actually owns the edited file, rather than +# the hook process's own ambient cwd. Edit/Write tool calls carry only an +# absolute file_path with no associated "current directory" state, so the +# hook's ambient cwd is not guaranteed to match the worktree the file lives +# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern +# guard-git.sh already uses on the read side of this same check. +# +# Walk up to the nearest existing ancestor directory first: Write can target +# a not-yet-created nested directory, and `git -C` requires an existing path. +SEARCH_DIR=$(dirname -- "$FILE_PATH") +while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do + SEARCH_DIR=$(dirname -- "$SEARCH_DIR") +done + +PROJECT_DIR="" +if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then + PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true +fi +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +fi LOG_FILE="$PROJECT_DIR/.claude/session-edits.log" # Normalize to relative path with forward slashes diff --git a/docs/examples/claude-code-skills/README.md b/docs/examples/claude-code-skills/README.md index 63fce698f..950980dfb 100644 --- a/docs/examples/claude-code-skills/README.md +++ b/docs/examples/claude-code-skills/README.md @@ -25,6 +25,8 @@ A single AI agent cannot hold an entire large codebase in context. The Titan Par │ │ │ └─→ /titan-gate (validates each commit) │ + ├─→ /titan-grind → adopts dead helpers from forge (Phase 4.5) + │ └─→ /titan-close → PRs + titan-report.md /titan-reset (escape hatch: clean up everything) @@ -40,6 +42,7 @@ A single AI agent cannot hold an entire large codebase in context. The Titan Par | `/titan-sync` | GLOBAL SYNC | Dependency clusters, code ownership, shared abstractions, ordered execution plan with logical commits | `sync.json` | | `/titan-forge` | FORGE | Executes the sync plan — makes code changes, validates with `/titan-gate`, commits, advances state. One phase per invocation | `titan-state.json` | | `/titan-gate` | STATE MACHINE | `codegraph check --staged --cycles --blast-radius 30 --boundaries` + lint/build/test. Snapshot restore on failure | `gate-log.ndjson` | +| `/titan-grind` | GRIND | Finds dead symbols from forge, wires them into consumers, replaces duplicated inline patterns, gates on dead-symbol delta | `grind-targets.ndjson` | | `/titan-close` | CLOSE | Splits branch commits into focused PRs, captures final metrics, generates comprehensive audit report with before/after comparison | `titan-report-*.md` | | `/titan-reset` | ESCAPE HATCH | Restores baseline snapshot, deletes all artifacts and snapshots, rebuilds graph | — | diff --git a/docs/examples/claude-code-skills/titan-gate/SKILL.md b/docs/examples/claude-code-skills/titan-gate/SKILL.md index a660bcae5..fbd495838 100644 --- a/docs/examples/claude-code-skills/titan-gate/SKILL.md +++ b/docs/examples/claude-code-skills/titan-gate/SKILL.md @@ -45,6 +45,8 @@ codegraph check --staged --cycles --blast-radius 30 --boundaries -T --json This checks: manifesto rules, new cycle introduction, blast radius threshold, and architecture boundary violations. Exit code 0 = pass, 1 = fail. +The `blast-radius` predicate is call-graph-shape-aware: a touched function's absolute transitive-caller count only counts toward the threshold if the diff actually changed that function's own declaration line or the set of call targets referenced in its body. A function whose body changed without touching a call expression or its own signature (e.g. an inline literal replaced by a named constant) is exempt even with a high absolute caller count — that fan-in is pre-existing risk already accepted by the codebase, not risk this diff introduced. This is the authoritative blast-radius pass/fail — see Step 8. + Also run detailed impact analysis: ```bash @@ -302,9 +304,9 @@ Advisory — prevents jumping ahead and creating conflicts. ## Step 8 — Blast radius check -From diff-impact results: -- Transitive blast radius > 30 → FAIL -- Transitive blast radius > 15 → WARN +The FAIL/PASS decision comes from Step 1's `codegraph check --staged --blast-radius 30` predicate — do not re-derive it from diff-impact's raw numbers, which are absolute transitive-caller counts and are NOT shape-aware: +- Step 1's `blast-radius` predicate failed → FAIL (a touched function's own call graph shape genuinely changed and its transitive callers exceed 30) +- Step 1's `blast-radius` predicate passed but a changed function's raw `transitiveCallers` (from diff-impact) > 15 → WARN (worth a second look even though not gating — this may include functions Step 1 exempted as pre-existing fan-in) - Historically coupled file NOT staged? → WARN ("consider also updating X") --- diff --git a/docs/examples/claude-code-skills/titan-grind/SKILL.md b/docs/examples/claude-code-skills/titan-grind/SKILL.md new file mode 100644 index 000000000..f6605c53e --- /dev/null +++ b/docs/examples/claude-code-skills/titan-grind/SKILL.md @@ -0,0 +1,729 @@ +--- +name: titan-grind +description: Adopt extracted helpers — find dead symbols from forge, wire them into consumers, replace duplicated inline patterns, and gate on dead-symbol delta (Titan Paradigm Phase 4.5) +argument-hint: <--dry-run> <--phase N> <--target name> <--yes> +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Skill, Agent +--- + +# Titan GRIND — Adopt Extracted Helpers + +You are running the **GRIND** phase of the Titan Paradigm. + +Forge shapes the metal. Grind smooths the rough edges. Your goal: find helpers that forge extracted but never wired into consumers, adopt them across the codebase, and gate on a non-positive dead-symbol delta. + +> **Why this phase exists:** Forge decomposes god-functions into smaller helpers, but those helpers are only called within their own file. The dead symbol count inflates with every forge phase because the adoption loop is never closed. Grind closes it. + +> **Context budget:** One forge phase per invocation. Process all targets from one forge phase's commits, then stop. If context reaches ~80% capacity mid-phase, write all state to disk, print a progress message, and stop — user re-runs for remainder. + +**Arguments** (from `$ARGUMENTS`): +- No args → process the next unground forge phase +- `--phase N` → process a specific forge phase +- `--target ` → run single target only (for retrying failures) +- `--dry-run` → analyze and classify without making code changes (classifications ARE persisted to `grind-targets.ndjson`) +- `--yes` → skip confirmation prompt (typically passed by `/titan-run` orchestrator) + +--- + +## Step 0 — Pre-flight + +1. **Worktree check:** + ```bash + git rev-parse --show-toplevel && git worktree list + ``` + If not in a worktree, stop: "Run `/worktree` first." + +2. **Session discovery.** Check all worktrees for titan artifacts: + ```bash + git worktree list + ``` + For each worktree, check: + ```bash + ls /.codegraph/titan/titan-state.json 2>/dev/null + ``` + - **Found artifacts in a different worktree:** Read its `titan-state.json → currentPhase` and `execution` block. If forge completed there but grind hasn't run, merge its branch: `git merge --no-edit` + - **Found artifacts in current worktree:** Proceed normally. + - **Found nothing:** Stop: "No Titan session found. Run `/titan-forge` first." + +3. **Sync with main:** + ```bash + git fetch origin main && git merge origin/main --no-edit + ``` + If merge conflicts → stop: "Merge conflict detected. Resolve and re-run `/titan-grind`." + +4. **Validate state file integrity:** + ```bash + node -e "try { JSON.parse(require('fs').readFileSync('.codegraph/titan/titan-state.json','utf8')); console.log('OK'); } catch(e) { console.log('CORRUPT: '+e.message); process.exit(1); }" + ``` + If CORRUPT → check `.codegraph/titan/titan-state.json.bak`: + - Backup valid → restore: `cp .codegraph/titan/titan-state.json.bak .codegraph/titan/titan-state.json` + - Backup also corrupt or missing → stop: "State file corrupted with no valid backup. Run `/titan-reset` and start over." + +5. **Load artifacts.** Read: + - `.codegraph/titan/titan-state.json` — current state (required) + - `.codegraph/titan/sync.json` — execution plan (required) + - `.codegraph/titan/gate-log.ndjson` — gate verdicts (optional) + - `.codegraph/titan/grind-targets.ndjson` — persisted grind analysis (optional, exists on resume) + - `.codegraph/titan/arch-snapshot.json` — pre-forge architectural snapshot (optional — if missing, gate's A1/A3/A4 checks will be skipped; print: "NOTE: No arch-snapshot.json found. Gate architectural comparisons will be skipped.") + - `.codegraph/titan/issues.ndjson` — cross-phase issue tracker (optional, for appending) + +6. **Validate state.** Grind runs after forge. Check: + - `titan-state.json → execution` block exists + - `execution.completedPhases` has at least one entry + - If no `execution` block → stop: "No forge execution found. Run `/titan-forge` first." + +7. **Initialize grind state** (if `grind` block doesn't exist in `titan-state.json`). Merge into `titan-state.json`: + ```json + { + "grind": { + "completedPhases": [], + "currentPhase": null, + "currentTarget": null, + "processedTargets": [], + "failedTargets": [], + "adoptions": [], + "removals": [], + "falsePositives": [], + "diffWarnings": [], + "deadSymbolBaseline": null, + "deadSymbolCurrent": null + } + } + ``` + +8. **Update top-level state.** Set `currentPhase` to `"grind"` in `titan-state.json`. Write immediately. + +9. **Back up state:** + ```bash + cp .codegraph/titan/titan-state.json .codegraph/titan/titan-state.json.bak + ``` + +10. **Ensure graph is current.** Rebuild if stale: + ```bash + codegraph build + ``` + +11. **Take a graph snapshot** before making changes: + ```bash + codegraph snapshot save titan-grind-baseline 2>/dev/null || true + ``` + +12. **Capture dead-symbol baseline** (only if `grind.deadSymbolBaseline` is null): + ```bash + codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})" + ``` + `codegraph roles --json` returns `{ count, summary, symbols }` (not a bare array) — `summary` is already the per-role breakdown (e.g. `dead-leaf`, `dead-entry`, `dead-ffi`, `dead-unresolved`), so no manual reduce is needed. + Store the total in `grind.deadSymbolBaseline`. Write `titan-state.json` immediately. + +13. **Drift detection.** Compare `titan-state.json → mainSHA` against current origin/main: + ```bash + git rev-list --count ..origin/main + ``` + If main has advanced, check if any forge-touched files were modified: + ```bash + git diff --name-only ..origin/main + ``` + Cross-reference against files from forge's `execution.commits`. If >20% of grind candidate files changed on main → **WARN**: "Main has advanced commits since forge. grind candidate files were modified. Some helpers may already be adopted or removed on main. Consider re-running `/titan-recon`." Continue unless >50% affected, then stop. + +14. **Determine next phase.** Use `--phase N` if provided, otherwise find the lowest forge phase number not in `grind.completedPhases`. + +15. **Update state.** Set `grind.currentPhase` to the target phase number. Write `titan-state.json`. + +16. **Record phase timestamp** (only if not already set — may exist from a prior crashed run): + ```bash + node -e "const fs=require('fs');const s=JSON.parse(fs.readFileSync('.codegraph/titan/titan-state.json','utf8'));s.phaseTimestamps=s.phaseTimestamps||{};s.phaseTimestamps['grind']=s.phaseTimestamps['grind']||{};if(!s.phaseTimestamps['grind'].startedAt){s.phaseTimestamps['grind'].startedAt=new Date().toISOString();fs.writeFileSync('.codegraph/titan/titan-state.json',JSON.stringify(s,null,2));}" + ``` + +17. **Print plan and ask for confirmation** (unless `--yes`): + ``` + GRIND — Phase N: