From f379a63094214e791270475c671a415b10da0cec Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 16:23:27 -0400 Subject: [PATCH 01/25] remove completed --- REVIEW_PLAN.md | 191 ---- .../2026-05-14-agent-search-artifact-mcp.md | 1006 ----------------- .../plans/2026-05-17-read-performance.md | 335 ------ 3 files changed, 1532 deletions(-) delete mode 100644 REVIEW_PLAN.md delete mode 100644 docs/superpowers/plans/2026-05-14-agent-search-artifact-mcp.md delete mode 100644 docs/superpowers/plans/2026-05-17-read-performance.md diff --git a/REVIEW_PLAN.md b/REVIEW_PLAN.md deleted file mode 100644 index 4244440e..00000000 --- a/REVIEW_PLAN.md +++ /dev/null @@ -1,191 +0,0 @@ -# Code Review Follow-up Plan - -Scope: `src/graphs`, `src/impact`, and `src/indexer`. - -## Checklist - -- [x] Thread `maxRefs` through impact reference lookup. - - `src/impact/analyzer.ts` applies `maxRefs` after `findReferences()` returns, so hot symbols can still scan and contextualize more references than needed. - - Suggested fix: pass `maxReferences` to `findReferences()` with enough headroom for test and ignore filtering, then keep the existing post-filter cap. - - Tests: impact analysis with a hot symbol, ignored/test refs, and `refContext` enabled. - -- [x] Reuse parsed context for namespace member reference lookup. - - `src/indexer/navigation.ts` builds or retrieves scope for namespace imports, then `collectNamespaceMemberRefs()` reparses the same file with no cached context. - - Suggested fix: pass parsed context, or a tree/source/sup tuple, into namespace member collection. - - Tests: namespace reference lookup with `keepParsed` enabled and disabled. - -- [x] Consolidate native and JS fallback import-binding conversion. - - `src/indexer/imports.ts` duplicates capture-to-`ImportBinding` conversion and per-language heuristics across native and JS fallback branches. - - Suggested fix: introduce a shared helper that accepts normalized captures and emits bindings. - - Tests: parity coverage for Java, C#, Go, Rust, Kotlin, Swift, Zig, C, and C++ import binding extraction. - -- [x] Share module specifier parsing between graph and index extraction. - - `src/graphs/specifiers.ts` has its own parser path for Python, PHP, Kotlin, Rust, C#, JS/TS fallback, HTML-like, and stylesheet specifiers. - - Suggested fix: factor common statement/specifier parsing below both graph specifiers and index import binding extraction. - - Tests: graph/index parity tests for extracted raw specifiers and type-only behavior. - -- [x] Deduplicate member-chain resolution in detailed symbol graph. - - `src/graphs/symbol-graph-detailed.ts` contains two near-identical chain walkers for namespace/member usage. - - Suggested fix: extract a shared chain resolver that accepts the emitting callback and label. - - Tests: `membersOnly` and full detailed symbol graph cases for optional chaining, subscript/string keys, and namespace imports. - -- [x] Review manifest/index build edge merging for duplicate handling cost. - - `src/indexer/build-index.ts` only deduplicates workspace manifest edges through `appendUniqueGraphEdges`; per-file graph edges are appended directly. - - Suggested fix: decide whether duplicate edges are expected from per-file collectors, and centralize edge key generation if deduplication is required. - - Tests: duplicate import edges from mixed graph/index paths and workspace manifest edges. - -- [x] Extract shared diff hunk utilities for impact workflows. - - Hunk line parsing exists in `src/impact/map.ts`, `src/impact/suggestions.ts`, `src/impact/report.ts`, and `src/impact/report-suggestions.ts`. - - Suggested fix: create a small shared module for changed-line collection, added/removed line text extraction, removed-line mapping, and new-file hunk ranges. - - Tests: deletion-only hunks, mixed replacement hunks, EOF deletion, and report hunk range rendering. - -- [x] Reuse graph adjacency helpers in impact context and test candidate discovery. - - `src/impact/context.ts` rebuilds forward and reverse dependency maps locally instead of using `index.graphAdjacency` or `buildGraphAdjacency`. - - Suggested fix: route file-subgraph and candidate-test traversal through existing graph adjacency helpers while preserving edge metadata where needed. - - Tests: candidate test ranking and N-hop context traversal with duplicate and type-only edges. - -- [x] Cap reference scans in untested-change suggestions. - - `src/impact/report-suggestions.ts` calls `findReferences()` for every changed symbol while building test coverage suggestions, but does not pass `maxReferences`. - - Suggested fix: add a small cap or option-derived cap for this probe, since it only needs to know whether any reference is in a test file. - - Tests: exported hot symbol with many references and at least one test reference. - -- [x] Reduce expensive streaming impact item deduplication. - - `src/impact/streaming.ts` deduplicates emitted items by `JSON.stringify(item)`, which can become expensive when context refs are included in large partial updates. - - Suggested fix: use a stable structural key based on file, phase, reasons, symbols, severity/depth, and ref count instead of serializing full context payloads. - - Tests: streaming with repeated partial updates and `refContext` enabled. - -## Second-Pass Complexity Findings - -- [x] Split the CLI dispatcher into shared command context plus focused command runners. - - `src/cli.ts` has a 1,188-line `runCliWithActiveRuntime()` block that mixes argument parsing, discovery setup, graph/index/review command execution, output formatting, report writing, and repeated build-option assembly. - - Suggested fix: extract a `CliCommandContext` builder, a `resolveCliScanPlan()` helper that returns files plus deleted/existing git state once, and move the remaining in-file command bodies into focused modules. Start with `graph`, `index`, `impact`, and `review` because they contain the most duplicated build/index/report plumbing. - - Tests: CLI regression coverage for `graph --sqlite`, `graph --symbols-detailed`, `index --json`, `impact --pretty`, `review --summary`, and include-root/git-diff combinations. - -- [x] Factor import extraction into language-specific extractors with a shared binding sink. - - `src/indexer/imports.ts` still has a 1,018-line `collectImportsForFile()` orchestration block. It now shares implicit binding conversion, but graph-only handling, Python regex import extraction, statement overrides, native capture handling, and JS/CJS fallback remain coupled in one function. - - Suggested fix: introduce an `ImportExtractionContext` with `resolveFrom()`, `pushBinding()`, fallback reporting, and source/language metadata; move Python, JS/CJS fallback, graph-only, and native statement override logic into separate modules under `src/indexer/imports/`. - - Tests: import binding parity for TypeScript/JavaScript, Python multiline and alias forms, Java/Kotlin/C#/Go/Rust/PHP native and fallback paths, and graph-only module specifier behavior. - -- [x] Decompose detailed symbol graph collection into reusable AST passes. - - `src/graphs/symbol-graph-detailed.ts` has a 690-line `buildSymbolGraphDetailed()` with nested walkers for definitions, aliases, member chains, calls, class inheritance, decorators, and Rust impls. - - Suggested fix: build per-file indexes (`localsByName`, imported alias maps, namespace maps), extract small collectors for `uses/calls`, member chains, decorators, class inheritance, and Rust impls, and run one merged traversal per function body where possible. - - Performance opportunity: current function-body processing walks the same subtree separately for alias uses, member uses, and calls; a merged visitor should reduce repeated AST traversal on large functions. - - Tests: detailed symbol graph edge cases for namespace members, `membersOnly`, decorator edges, Java/C# inheritance, Rust impls, optional chaining, and max-edge pruning. - -- [x] Split review report generation into staged pipeline helpers. - - `src/review.ts` has a 485-line `buildReviewReport()` that handles change discovery, diff normalization, deleted snapshots, incremental indexing, changed-symbol mapping, reference lookups, file summaries, graph delta, candidate tests, SQL context, risk summary, and final report assembly. - - Suggested fix: extract `collectReviewChanges()`, `buildReviewIndex()`, `summarizeChangedFiles()`, `collectReviewGraphDelta()`, and `assembleReviewReport()` so each stage has explicit inputs/outputs and can be tested directly. - - Correctness opportunity: make deleted/missing/ignored file status transitions explicit in one data structure instead of spreading them across changed-file sets, diff maps, and final summary branches. - - Tests: raw diff, git `WORKTREE`, deleted files, missing explicit files, ignored diff files, include-symbol-details, SQL context, and candidate-test sorting. - -- [x] Share full and incremental index-build state machines. - - `src/indexer/build-index.ts` has two large overlapping flows: `buildIndexFromFileListShared()` at 303 lines and `buildProjectIndexIncremental()` at 371 lines. Both initialize reports, graph options, file signatures, worker pools, parsed caches, bloom filters, JSON dependency modules, graph adjacency, manifest writes, and final `ProjectIndex` assembly. - - Suggested fix: extract reusable helpers for `IndexBuildRunState`, `prepareFileSignatures()`, `buildFileModules()`, `writeIndexManifest()`, `finalizeProjectIndex()`, and parsed-cache trimming. Keep full and incremental file-selection logic separate, but share execution and finalization. - - Performance opportunity: centralizing file signature and cached-edge reuse should make it easier to avoid recomputing graph edges or SQL fact caches when only module cache state changed. - - Tests: cache modes, incremental strict, manifest option mismatch, deleted tracked files, SQL corpus signatures, worker pool parity, and parsed-cache reuse. - -- [x] Split multi-language resolution into per-language modules and unify project symbol indexes. - - `src/util/resolution.ts` is 1,872 lines and combines TS path config, generic specifier resolution, node_modules resolution, JVM package indexes, PHP composer/symbol scanning, Go module/workspace handling, and Python package resolution. - - Suggested fix: move language-specific resolvers into `src/util/resolution/{go,jvm,php,python,node}.ts` and keep `resolveImportSpecifier()` as a small dispatcher. Share a generic project-symbol-index builder for Java, Kotlin, and PHP while keeping PHP's multi-namespace/kind metadata. - - Correctness opportunity: PHP, Java, and Kotlin symbol indexers use regex/token scanners independent of native parsed symbols; consider reusing native/local export extraction where available so import resolution and indexed symbols stay aligned. - - Tests: resolution regression suites for Java, Kotlin, PHP Composer/autoload/classmap, Go workspaces, Python namespace packages, TS paths, node_modules exports, and cache clearing. - -## Third-Pass Complexity Findings - -- [x] Share declaration and export extraction infrastructure across locals, scope, and impact mapping. - - `src/indexer/locals-and-exports.ts` has a 598-line `collectLocalsAndExportsFromSource()` and `src/indexer/scope.ts` has a 263-line `buildScopeIndexFromSource()`; both classify declaration names, compute ranges, walk parameters, and interpret language-specific definition nodes. `src/impact/map.ts` also repeats declaration-name checks while locating changed symbols. - - Suggested fix: introduce a shared declaration walker that emits normalized declaration events (`name`, `kind`, `range`, `node`, `scopeRole`) and let locals/export extraction, scope indexing, and impact mapping consume those events. - - Correctness opportunity: this would keep language additions from updating locals but not scope, or scope but not impact symbol mapping. - - Tests: scope-quality, changed-line mapping, and cross-language local/export extraction for TypeScript, Python, Go, Rust, Kotlin, Swift, C/C++, PHP, and Ruby. - -- [x] Split module specifier extraction from edge resolution and reuse fallback/query handling. - - `src/graphs/specifiers.ts` has a 330-line `collectModuleSpecifiersFromSource()` and `src/graph-edge-collector.ts` has a 109-complexity `collectEdgesForFile()`. Both contain language routing, fallback reporting, graph-only handling, HTML/style extras, and per-language resolution decisions. - - Suggested fix: make specifier extraction return normalized `ModuleSpecifier` values plus diagnostics only, then move edge target resolution into a table of language-specific resolver functions. Share helper code for native query execution, JS fallback recovery, HTML/style appended specifiers, and graph-only resolution config. - - Performance opportunity: the edge collector currently resolves all specifiers via `Promise.all`, which can burst filesystem work for large files; a bounded resolver queue would make graph builds smoother without changing results. - - Tests: fallback import extraction diagnostics, graph-only document links, CSS/SCSS/Less URL imports, HTML/Vue/Svelte/Astro imports, PHP qualified references, JVM package fan-out, and dynamic import heuristics. - -- [x] Unify member-access parsing for goto, references, and detailed symbol graph. - - Member/object/property extraction appears in `src/indexer/navigation-goto.ts`, `src/indexer/navigation.ts` (`collectNamespaceMemberRefs()`), and `src/graphs/symbol-graph-detailed.ts`. Each handles Python/Ruby/Go/Java/C#/Kotlin/Swift member shapes with local variations. - - Suggested fix: create a `memberAccess.ts` helper that normalizes member nodes into `{ object, property, chain }` and supports language-specific node shapes. Use it in goto, namespace reference collection, and detailed symbol graph member/call collectors. - - Correctness opportunity: optional chaining, Kotlin/Swift navigation expressions, Ruby scope resolution, and Go qualified types should resolve consistently across goto, refs, and symbol graph edges. - - Tests: `goto`, `references`, and detailed-symbol edge cases for namespace members, optional/member chains, Ruby `::`, Go qualified types, Java static members, C# nested members, Kotlin aliases, and Swift static factories. - -- [x] Extract shared SQL lexical helpers and reuse them across facts, navigation, and MCP query checks. - - `splitTopLevelCommaSeparated()` is duplicated in `src/sql/extractFacts.ts` and `src/sql/navigation.ts`; paren-depth logic is also duplicated. `src/mcp/server.ts` has its own SQL comment/literal stripper for resource checks. - - Suggested fix: create `src/sql/lex.ts` for top-level splitting, paren depth, comment/string masking, and bounded identifier scanning. Reuse it in fact extraction, SQL navigation, and MCP SQLite query validation. - - Correctness opportunity: SQL parsing edge cases such as quoted strings, comments, nested expressions, and CTE clauses should not diverge between review facts and navigation. - - Tests: SQL fact extraction, SQL navigation, SQL review context, and MCP `query_sqlite` resource-bound rejection tests. - -- [x] Share native binding contracts between runtime and worker code. - - `NativeBinding` is declared separately in `src/native/treeSitterNative.ts` and `src/worker/nativeExtractWorker.ts`, with slightly different optional capabilities. - - Suggested fix: move the binding interface and result/fallback reason contracts into a shared native module imported by both runtime and worker code. - - Correctness opportunity: adding a native capability such as syntax-tree parsing or compact queries should update one contract instead of relying on duplicated structural types. - - Tests: native binding loader, native worker parity, native runtime mode, and native parser ownership tests. - -- [x] Decompose project-file discovery parsers into manifest-specific helpers. - - `src/util/projectFiles.ts` combines discovery traversal with many manifest-name parsers (`package.json`, TOML, INI, setup.py, Maven, Gradle, .NET, Go, Gem, Swift) and a long `PROJECT_FILE_DEFINITIONS` registry. - - Suggested fix: move parser helpers and definitions into `src/util/projectFiles/definitions.ts` and `src/util/projectFiles/parsers.ts`, leaving traversal/path-safety logic in the main module. - - Correctness opportunity: parser tests can cover manifest name extraction directly, instead of only through full project discovery. - - Tests: project file discovery, workspace detection, language parity docs fixtures where project metadata affects external classification. - -- [x] Split MCP server into tool registry, HTTP transport, file security, and SQL guard modules. - - `src/mcp/server.ts` is 1,286 lines and mixes MCP tool handlers, Streamable HTTP session handling, Host-header checks, JSON body parsing, file confinement, file-prefix reads, UTF-8 truncation, SQLite query guarding, and row/byte bounding. - - Suggested fix: extract `mcp/tools.ts`, `mcp/http.ts`, `mcp/security.ts`, and `mcp/sqliteGuard.ts`, keeping `server.ts` as composition glue. - - Reuse opportunity: UTF-8 truncation and path confinement are general utilities that could also support artifact/file-reading code paths. - - Tests: MCP server tests for tool handlers, HTTP Host validation, request size limits, artifact path confinement, file reads, SQLite row/byte limits, and unsupported SQL functions. - -- [x] Centralize agent limit normalization and bounded output shaping. - - `src/agent/search.ts`, `src/agent/explain.ts`, and `src/agent-tools.ts` each normalize limits, bound results, and convert file/module references into agent-facing shapes. Handle formatting is shared, but bounded-list and output normalization policies are still scattered. - - Suggested fix: introduce `src/agent/bounds.ts` and `src/agent/normalize.ts` for limit clamping, bounded-list metadata, relative path normalization, and common follow-up command shaping. - - Correctness opportunity: agent search, explain, artifact questions, and tool wrappers should report omission counts and path shapes consistently. - - Tests: agent search, agent explain, agent tools, artifact build, and package metadata API-surface tests. - -## Fourth-Pass Complexity Findings - -- [x] Split native runtime orchestration from native query execution helpers. - - `src/native/treeSitterNative.ts` is a high fan-in hotspot and now owns binding loading, runtime-mode enforcement, normalized query caching, compact/full query execution, single-query execution, JS fallback bridging, and syntax-tree parsing. - - Suggested fix: extract native binding state/runtime-mode helpers, normalized query metadata, query execution wrappers, and JS fallback bridging into focused modules under `src/native/`. Keep the public runtime facade small and preserve existing exported entry points. - - Correctness opportunity: compact imports, full language queries, ad-hoc queries, and syntax-tree parsing repeat fallback reason/error shaping; one result-normalization helper would keep native-required failures and unsupported-language behavior consistent. - - Tests: native runtime mode, native query normalization, compact imports fallback, native parser ownership, native worker parity, and explicit native-required failure paths. - -- [x] Decompose SQLite persistence into schema, write/update, and query modules. - - `src/sqlite.ts` is 920 lines and combines schema creation/migration, insert/delete helpers, full writes, incremental updates, canned graph queries, raw read-only query validation, and snapshot metadata. - - Suggested fix: split `sqlite/schema.ts`, `sqlite/write.ts`, `sqlite/update.ts`, `sqlite/query.ts`, and `sqlite/guards.ts`. Keep schema-version handling and migration helpers isolated so persistent storage changes have one upgrade path. - - Correctness opportunity: `ensureSchema()` creates tables and patches columns in the same file as query execution; separating schema upgrades would make older on-disk database regression tests easier to maintain. - - Tests: SQLite full write, incremental update/delete, schema migration from older fixtures, raw read-only guard behavior, artifact SQLite generation, and MCP SQLite query paths. - -- [x] Extract impact report assembly into reusable compact/full report stages. - - `src/impact/report.ts` has a 265-line `buildCompactReport()` plus full-report assembly, re-export chain discovery, top impacts, clusters, cycles, and surface-area summaries in one module. - - Suggested fix: introduce staged helpers for display-file normalization, file-index construction, compact serializers, graph summary sections, re-export chains, top impacts, clusters, and surface area. Share the precomputed file/symbol indexes between compact and full formats. - - Performance opportunity: compact report construction repeatedly calls `displayFile()` and looks up file indexes across each section; a shared serializer context can avoid repeated normalization and make missing-index errors explicit. - - Tests: compact/full impact reports, project file metadata, re-export chains, graph cycles, clusters, surface area summaries, top impacts, and schema-version compatibility. - -- [x] Split impact analyzer into direct-reference, transitive, and severity calculators. - - `src/impact/analyzer.ts` has a 221-line `analyzeImpact()` that handles option normalization, ignore/test matchers, bounded reference lookup, streaming emission, direct impact merging, file-level changes, and transitive propagation. The same module also owns severity scoring. - - Suggested fix: extract `impact/direct.ts`, `impact/transitive.ts`, and `impact/severity.ts` around a shared `ImpactAnalysisContext` containing matchers, dependency stats, diagnostics, and emit hooks. - - Correctness opportunity: include-test and ignore-glob policy is rebuilt across direct and transitive phases; one context would keep filtering and diagnostics consistent as new impact reasons are added. - - Tests: direct refs with `maxRefs`, ignored/test refs, file-level changes, transitive depth/type-only edges, diagnostics counters, streaming partial items, and severity weight overrides. - -- [x] Centralize build-cache option normalization, manifest comparison, and reports. - - `src/indexer/build-cache.ts` is 807 lines and mixes workspace manifest edges, memory/disk module cache, file signatures, fallback extraction reports, manifest IO, build-option summaries, diffing, and graph-option equality. - - Suggested fix: split cache option normalization/equality into `indexer/build-cache/options.ts`, manifest IO/verification into `manifest.ts`, module cache read/write into `module-cache.ts`, and report shaping into `reports.ts`. - - Correctness opportunity: the same normalized option shapes drive manifest writes, manifest diffs, and graph-option equality; a single typed comparer would reduce false rebuilds and missed rebuilds when discovery or graph options change. - - Tests: cache invalidation, cache strict/off modes, discovery option normalization, graph option equality, fallback extraction report aggregation, disk cache reuse, and manifest mismatch messages. - -- [x] Decompose graph-only document link extraction and chunking by format. - - `src/documentLinks.ts` is 692 lines and routes Markdown, MDX, Astro, Handlebars, reStructuredText, AsciiDoc, HTML attributes, inline scripts, link normalization, and Markdown parsing in one file. `src/chunking/chunkFile.ts` is another large format-sensitive flow for block extraction, splitting, merging, and gap filling. - - Suggested fix: move document extractors into `documentLinks/{markdown,html,rst,asciidoc,sfc}.ts` behind a small dispatcher, and split chunking into match collection, block classification, large-block splitting, merge, and gap-fill helpers. - - Correctness opportunity: document specifier normalization should match graph-only edge extraction, chunking, and source-style imports for mixed formats like MDX/Astro/SFC files. - - Tests: Markdown reference/inline links, MDX/Astro/Handlebars imports, RST toctrees/targets, AsciiDoc xref/include/link forms, HTML `srcset`/inline scripts, CSS-style URLs, and chunk splitting/merge behavior. - -- [x] Split external dependency classification into manifest parsers, stdlib tables, and context lookup. - - `src/graphs/external-classifier.ts` is 743 lines and contains large stdlib tables, manifest parsers for many ecosystems, ancestor-boundary search, package-name matching, caches, and final external classification. - - Suggested fix: move ecosystem manifest readers into `graphs/external/manifests.ts`, stdlib/module tables into `stdlib.ts`, context/ancestor lookup into `context.ts`, and leave `classifyExternalSpecifier()` as a small coordinator. - - Correctness opportunity: manifest parsing overlaps with project-file discovery but uses separate parsing rules; extracting parser units makes it easier to align dependency detection with discovery fixtures and language parity claims. - - Tests: unresolved import classification for Node, Python, Ruby, Go, Rust, Zig, Java/Kotlin, .NET, C/C++, Swift, Composer, nested manifests, VCS boundaries, and cache reset/stats. - -- [x] Separate graph query parsing and traversal execution from SQLite query dispatch. - - `src/query.ts`, `src/graphs/queries.ts`, `src/cli/graphQueries.ts`, and `src/sqlite.ts` each participate in graph-query parsing, graph traversal, result bounding, cycle detail construction, and canned SQL-backed query dispatch. - - Suggested fix: introduce a typed query AST/parser module and execution modules for in-memory graph queries and SQLite-backed canned queries. Reuse traversal helpers for neighbors, shortest paths, reverse dependencies, cycles, and unresolved imports. - - Performance opportunity: `querySymbolNeighbors()` builds incoming/outgoing maps per call and then scans all edges again to materialize results; reusable adjacency indexes would help interactive agents and CLI graph queries on large symbol graphs. - - Tests: text query parsing, symbol neighbor depth/direction/label filters, detailed cycle ordering/remediation hints, unresolved import classification, CLI graph query output, and SQLite canned query parity. diff --git a/docs/superpowers/plans/2026-05-14-agent-search-artifact-mcp.md b/docs/superpowers/plans/2026-05-14-agent-search-artifact-mcp.md deleted file mode 100644 index d46d60da..00000000 --- a/docs/superpowers/plans/2026-05-14-agent-search-artifact-mcp.md +++ /dev/null @@ -1,1006 +0,0 @@ -# Agent Search, Artifact, and MCP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a unified agent-facing workflow around Codegraph's existing graph, index, SQLite, review, and SQL support: `codegraph search`, `codegraph explain`, `codegraph artifact build`, and an MCP server exposing the same primitives. - -**Architecture:** Build one shared service layer over existing deterministic artifacts, then attach CLI and MCP frontends to that layer. The first pass must not introduce embeddings, LLM calls, new persistent SQLite schema, or a second analysis engine; it should compose `buildProjectIndex`, `collectGraph`, symbol graphs, chunking, review/impact, and `writeGraphSqlite`. - -**Tech Stack:** TypeScript, Vitest, current Codegraph CLI runtime, `better-sqlite3`, existing native Tree-sitter runtime, existing `@modelcontextprotocol/sdk` only if added explicitly as a dependency for MCP. - ---- - -## Product Contract - -The four additions are deliberately layered: - -1. `codegraph search`: deterministic ranked search across files, symbols, chunks, SQL objects, and graph neighborhoods. -2. `codegraph explain`: compact architecture packet for one file, symbol, or SQL object, with optional changed-context. -3. `codegraph artifact build`: one command that writes `codegraph.sqlite`, graph JSON, optional `CODEGRAPH_REPORT.md`, suggested questions, and a manifest. -4. MCP server: typed tools over the same services: `search`, `get_file`, `get_symbol`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite`. - -SQL extraction is not a gap in this plan. SQL must participate in search, explain, artifact build, and MCP from the existing SQL language support: `.sql` discovery, statement chunks, SQL object symbols, SQL-to-SQL graph edges, SQL navigation, SQL review context, and native-only operation. The remaining SQL limitation is current-schema reconstruction. - -## Files And Boundaries - -- Create `src/agent/search.ts`: search request/result types, deterministic scoring, result evidence, graph-neighborhood expansion. -- Create `src/agent/explain.ts`: file/symbol/SQL target explanation packets built from search/index/graph/review data. -- Create `src/agent/artifact.ts`: artifact build orchestration and manifest/report/question writers. -- Create `src/agent/session.ts`: shared in-process project session and cache used by CLI and MCP. -- Create `src/mcp/server.ts`: MCP tool registration and tool handlers over `src/agent/session.ts`. -- Modify `src/cli.ts`: dispatch `search`, `explain`, `artifact build`, and `mcp serve` commands. -- Modify `src/cli/help.ts`: include the new commands and examples. -- Modify `src/index.ts`: export library APIs and types for search, explain, artifact, and MCP/session helpers where public. -- Modify `docs/cli.md`: document new commands, flags, output contracts, and examples. -- Modify `docs/library-api.md`: document new TypeScript APIs. -- Modify `docs/agent-workflows.md`: document search-to-explain-to-impact workflows. -- Modify `codegraph-skill/codegraph/SKILL.md`: expose the new agent-facing commands. -- Modify `README.md`: add concise feature bullets and docs links only. -- Test `tests/agent-search.test.ts`: real fixture coverage for search behavior. -- Test `tests/agent-explain.test.ts`: real fixture coverage for explain packets. -- Test `tests/artifact-build.test.ts`: artifact outputs and manifest/report/question behavior. -- Test `tests/mcp-server.test.ts`: MCP handlers using a real temp repo and in-process session. -- Modify `tests/cli-regressions.test.ts`: command-level coverage for CLI output and errors. - -## Shared Types - -Define these in `src/agent/search.ts` and reuse them everywhere: - -```ts -export type AgentSearchMode = "hybrid" | "symbol" | "path" | "text" | "graph" | "sql"; - -export type AgentSearchRequest = { - root: string; - query: string; - mode?: AgentSearchMode; - from?: string; - depth?: number; - limit?: number; - includeSnippets?: boolean; -}; - -export type AgentSearchResultKind = "file" | "symbol" | "chunk" | "sql_object" | "graph_node"; - -export type AgentSearchEvidence = { - source: "path" | "symbol" | "chunk" | "graph" | "sql"; - label: string; - file?: string; - line?: number; - snippet?: string; -}; - -export type AgentSearchResult = { - handle: string; - kind: AgentSearchResultKind; - label: string; - file: string; - range?: { - start: { line: number; column: number }; - end: { line: number; column: number }; - }; - score: number; - rankReasons: string[]; - evidence: AgentSearchEvidence[]; - neighbors: Array<{ relation: string; target: string; file?: string }>; - followUps: string[]; - omittedCounts: { - rankReasons: number; - evidence: number; - neighbors: number; - followUps: number; - }; -}; - -export type AgentSearchResponse = { - schemaVersion: 1; - query: string; - mode: AgentSearchMode; - root: string; - limits: { - results: number; - rankReasonsPerResult: number; - evidencePerResult: number; - neighborsPerResult: number; - followUpsPerResult: number; - }; - results: AgentSearchResult[]; -}; -``` - -Define these in `src/agent/explain.ts`: - -```ts -export type AgentExplainTarget = { - root: string; - target: string; - base?: string; - head?: string; - includeChangedContext?: boolean; - maxDependencies?: number; - maxSnippets?: number; - maxSymbols?: number; -}; - -export type AgentExplanation = { - schemaVersion: 1; - target: { - handle: string; - kind: "file" | "symbol" | "sql_object"; - label: string; - file: string; - }; - summary: string[]; - symbols: Array<{ name: string; kind: string; handle: string; line?: number }>; - dependencies: Array<{ file: string; reason: string }>; - reverseDependencies: Array<{ file: string; reason: string }>; - relatedSqlObjects: Array<{ name: string; file: string; relation: string }>; - changedContext?: { - changedFiles: string[]; - reviewTasks: Array<{ id: string; reason: string; summary: string }>; - candidateTests: Array<{ file: string; confidence: string; reason: string }>; - }; - followUps: string[]; - limits: { symbols: number; dependencies: number; snippets: number }; - omittedCounts: { - symbols: number; - dependencies: number; - reverseDependencies: number; - references: number; - relatedSqlObjects: number; - snippets: number; - }; -}; -``` - -## Task 1: Shared Agent Session - -**Files:** -- Create: `src/agent/session.ts` -- Test: `tests/agent-session.test.ts` -- Modify: `src/index.ts` - -- [x] **Step 1: Write the failing session cache test** - -Add `tests/agent-session.test.ts`: - -```ts -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { createAgentSession } from "../src/agent/session.js"; - -async function mkRepo(): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-")); - await fs.writeFile(path.join(root, "util.ts"), "export function add(a: number, b: number) { return a + b; }\n"); - await fs.writeFile(path.join(root, "main.ts"), "import { add } from './util';\nexport const total = add(1, 2);\n"); - await fs.writeFile(path.join(root, "schema.sql"), "CREATE TABLE public.users (id int primary key);\n"); - return root; -} - -describe("agent session", () => { - it("loads index, graph, symbol graph, and SQL facts once for repeated agent operations", async () => { - const root = await mkRepo(); - const session = createAgentSession({ root }); - - const first = await session.loadProject(); - const second = await session.loadProject(); - - expect(second).toBe(first); - expect(first.files.some((file) => file.endsWith("schema.sql"))).toBeTruthy(); - expect(first.symbolGraph.nodes.size).toBeGreaterThan(0); - expect(first.fileGraph.nodes.size).toBeGreaterThan(0); - }); -}); -``` - -- [x] **Step 2: Run the failing test** - -Run: `npx vitest run tests/agent-session.test.ts` - -Expected: FAIL because `src/agent/session.ts` does not exist. - -- [x] **Step 3: Implement the session** - -Create `src/agent/session.ts` with: - -```ts -import { buildProjectIndex } from "../indexer.js"; -import { collectGraph, buildSymbolGraphDetailed } from "../graphs.js"; -import { listProjectFiles } from "../util.js"; -import type { Graph } from "../types.js"; -import type { ProjectIndex } from "../indexer/types.js"; -import type { SymbolGraph } from "../graphs.js"; - -export type AgentProjectSnapshot = { - root: string; - files: string[]; - index: ProjectIndex; - fileGraph: Graph; - symbolGraph: SymbolGraph; -}; - -export type AgentSessionOptions = { - root: string; -}; - -export type AgentSession = { - loadProject: () => Promise; - invalidate: () => void; -}; - -export function createAgentSession(options: AgentSessionOptions): AgentSession { - let cached: Promise | undefined; - - const loadProject = async (): Promise => { - if (cached) return cached; - cached = (async () => { - const files = await listProjectFiles(options.root); - const index = await buildProjectIndex(options.root); - const fileGraph = await collectGraph(options.root, files, { allFiles: files }); - const symbolGraph = buildSymbolGraphDetailed(index); - return { - root: options.root, - files, - index, - fileGraph, - symbolGraph, - }; - })(); - return cached; - }; - - return { - loadProject, - invalidate: () => { - cached = undefined; - }, - }; -} -``` - -Use the exact current exported type names from `src/indexer/types.ts`, `src/graphs.ts`, and `src/types.ts`. Do not use `any`. - -- [x] **Step 4: Export the session API** - -Modify `src/index.ts`: - -```ts -export { createAgentSession } from "./agent/session.js"; -export type { AgentProjectSnapshot, AgentSession, AgentSessionOptions } from "./agent/session.js"; -``` - -- [x] **Step 5: Verify and commit** - -Run: - -```bash -npx vitest run tests/agent-session.test.ts -npm run build -npm run lint -``` - -Expected: PASS. - -Commit: - -```bash -git add src/agent/session.ts src/index.ts tests/agent-session.test.ts -git commit -m "feat: add agent project session" -``` - -## Task 2: `codegraph search` - -**Files:** -- Create: `src/agent/search.ts` -- Test: `tests/agent-search.test.ts` -- Modify: `src/cli.ts` -- Modify: `src/cli/help.ts` -- Modify: `src/index.ts` -- Modify: `tests/cli-regressions.test.ts` - -- [x] **Step 1: Write failing library tests** - -Add `tests/agent-search.test.ts`: - -```ts -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { searchCodegraph } from "../src/agent/search.js"; - -async function mkRepo(): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-search-")); - await fs.mkdir(path.join(root, "src")); - await fs.writeFile(path.join(root, "src", "auth.ts"), "export function validateUser(token: string) { return token.length > 0; }\n"); - await fs.writeFile(path.join(root, "src", "api.ts"), "import { validateUser } from './auth';\nexport function handleLogin(token: string) { return validateUser(token); }\n"); - await fs.writeFile(path.join(root, "schema.sql"), "CREATE TABLE public.users (id int primary key, email text);\nCREATE VIEW active_users AS SELECT id FROM public.users;\n"); - return root; -} - -describe("agent search", () => { - it("ranks exact symbol, path, chunk, and graph evidence with follow-up commands", async () => { - const root = await mkRepo(); - const response = await searchCodegraph({ root, query: "validate user auth", mode: "hybrid", limit: 5 }); - - expect(response.schemaVersion).toBe(1); - expect(response.results[0]?.label).toContain("validateUser"); - expect(response.results[0]?.rankReasons.length).toBeGreaterThan(0); - expect(response.results[0]?.followUps.some((cmd) => cmd.includes("codegraph refs"))).toBeTruthy(); - expect(response.results.some((result) => result.file.endsWith("src/auth.ts"))).toBeTruthy(); - }); - - it("includes SQL object results from .sql language support", async () => { - const root = await mkRepo(); - const response = await searchCodegraph({ root, query: "public users", mode: "sql", limit: 5 }); - - expect(response.results.some((result) => result.kind === "sql_object" && result.label.includes("public.users"))).toBeTruthy(); - expect(response.results.every((result) => result.score > 0)).toBeTruthy(); - }); -}); -``` - -- [x] **Step 2: Run the failing tests** - -Run: `npx vitest run tests/agent-search.test.ts` - -Expected: FAIL because `searchCodegraph` does not exist. - -- [x] **Step 3: Implement deterministic search** - -Create `src/agent/search.ts` using the shared types above. - -Implementation requirements: - -- Build through `createAgentSession({ root }).loadProject()` for this task. -- Score path matches, symbol-name matches, docstring/chunk matches, graph-neighborhood matches, and SQL object matches. -- Keep scoring deterministic: no random values, no timing-sensitive ordering. -- Sort by score descending, then label ascending, then file ascending. -- Generate stable handles: - - File: `file:` - - Symbol: `symbol:` - - SQL object: `sql:::` -- Include `rankReasons`, `evidence`, `neighbors`, and `followUps`. -- Do not add a SQLite schema change in this task. - -The public function signature must be: - -```ts -export async function searchCodegraph(request: AgentSearchRequest): Promise; -``` - -- [x] **Step 4: Add CLI command** - -Modify `src/cli.ts` command dispatch: - -```ts -if (cmd === "search") { - const query = args.find((arg) => !arg.startsWith("--")); - if (!query) { - writeStderrLine('Usage: search "" [--root ] [--mode hybrid|symbol|path|text|graph|sql] [--limit ] [--json]'); - return exitCli(1); - } - const response = await searchCodegraph({ - root: projectRootFs, - query, - mode, - limit, - includeSnippets, - }); - if (json) writeJSONLine(response); - else writeStdoutLine(formatAgentSearchResponse(response)); - return; -} -``` - -Implement the command with the same option parsing style used by neighboring commands in `src/cli.ts`. The required behavior is exactly the usage string, request fields, JSON output, and text output described above. - -- [x] **Step 5: Add CLI regression coverage** - -Append to `tests/cli-regressions.test.ts`: - -```ts -it("search returns ranked agent-ready results", async () => { - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "cg-cli-search-")); - await fsp.writeFile(path.join(root, "auth.ts"), "export function validateUser(token: string) { return token.length > 0; }\n"); - await fsp.writeFile(path.join(root, "main.ts"), "import { validateUser } from './auth';\nexport const ok = validateUser('token');\n"); - - const stdout = await runCliCommand(["search", "validate user", "--root", root, "--json"]); - const response = JSON.parse(stdout) as { results: Array<{ label: string; rankReasons: string[]; followUps: string[] }> }; - - expect(response.results[0]?.label).toContain("validateUser"); - expect(response.results[0]?.rankReasons.length).toBeGreaterThan(0); - expect(response.results[0]?.followUps.some((cmd) => cmd.includes("codegraph refs"))).toBeTruthy(); -}); -``` - -- [x] **Step 6: Update exports, help, docs, and skill** - -Export from `src/index.ts`: - -```ts -export { searchCodegraph } from "./agent/search.js"; -export type { - AgentSearchEvidence, - AgentSearchMode, - AgentSearchRequest, - AgentSearchResponse, - AgentSearchResult, - AgentSearchResultKind, -} from "./agent/search.js"; -``` - -Update `src/cli/help.ts`, `docs/cli.md`, `docs/library-api.md`, `docs/agent-workflows.md`, `README.md`, and `codegraph-skill/codegraph/SKILL.md` with concise examples. - -- [x] **Step 7: Verify and commit** - -Run: - -```bash -npx vitest run tests/agent-search.test.ts tests/cli-regressions.test.ts -npm run build -npm run lint -npm run test:ci -``` - -Expected: PASS. - -Commit: - -```bash -git add src/agent/search.ts src/cli.ts src/cli/help.ts src/index.ts tests/agent-search.test.ts tests/cli-regressions.test.ts README.md docs/cli.md docs/library-api.md docs/agent-workflows.md codegraph-skill/codegraph/SKILL.md -git commit -m "feat: add agent search" -``` - -## Task 3: `codegraph explain` - -**Files:** -- Create: `src/agent/explain.ts` -- Test: `tests/agent-explain.test.ts` -- Modify: `src/cli.ts` -- Modify: `src/cli/help.ts` -- Modify: `src/index.ts` -- Modify: `tests/cli-regressions.test.ts` - -- [x] **Step 1: Write failing explain tests** - -Add `tests/agent-explain.test.ts`: - -```ts -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { explainCodegraphTarget } from "../src/agent/explain.js"; - -async function mkRepo(): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-explain-")); - await fs.writeFile(path.join(root, "users.sql"), "CREATE TABLE public.users (id int primary key);\n"); - await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); - await fs.writeFile(path.join(root, "api.ts"), "import { validateUser } from './auth';\nexport function handler(id: number) { return validateUser(id); }\n"); - return root; -} - -describe("agent explain", () => { - it("explains a file with symbols, dependencies, reverse dependencies, and follow-ups", async () => { - const root = await mkRepo(); - const explanation = await explainCodegraphTarget({ root, target: "auth.ts" }); - - expect(explanation.schemaVersion).toBe(1); - expect(explanation.target.file).toBe("auth.ts"); - expect(explanation.symbols.some((symbol) => symbol.name === "validateUser")).toBeTruthy(); - expect(explanation.reverseDependencies.some((entry) => entry.file === "api.ts")).toBeTruthy(); - expect(explanation.followUps.some((cmd) => cmd.includes("codegraph refs"))).toBeTruthy(); - }); - - it("explains SQL objects without claiming current-schema reconstruction", async () => { - const root = await mkRepo(); - const explanation = await explainCodegraphTarget({ root, target: "public.users" }); - - expect(explanation.target.kind).toBe("sql_object"); - expect(explanation.relatedSqlObjects.some((entry) => entry.name === "public.users")).toBeTruthy(); - expect(explanation.summary.join(" ")).not.toContain("current schema"); - }); -}); -``` - -- [x] **Step 2: Run the failing tests** - -Run: `npx vitest run tests/agent-explain.test.ts` - -Expected: FAIL because `explainCodegraphTarget` does not exist. - -- [x] **Step 3: Implement explain packets** - -Create `src/agent/explain.ts`. - -Implementation requirements: - -- Resolve `target` using search handles, file paths, symbol names, and SQL object names. -- For files, include local symbols, dependencies, reverse dependencies, hotspots when relevant, and follow-ups. -- For symbols, include defining file, references, dependencies around the file, and follow-ups. -- For SQL objects, use existing SQL object symbols/facts and explicitly avoid current-schema reconstruction claims. -- If `includeChangedContext` plus `base`/`head` are provided, call existing review/impact code and include compact changed context. -- Keep payload bounded by `maxDependencies` and `maxSnippets`. - -The public function signature must be: - -```ts -export async function explainCodegraphTarget(request: AgentExplainTarget): Promise; -``` - -- [x] **Step 4: Add CLI command and tests** - -Add `codegraph explain [--root ] [--changed-context --base --head ] [--json]`. - -Append CLI regression: - -```ts -it("explain returns compact architecture context", async () => { - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "cg-cli-explain-")); - await fsp.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); - await fsp.writeFile(path.join(root, "api.ts"), "import { validateUser } from './auth';\nexport const ok = validateUser(1);\n"); - - const stdout = await runCliCommand(["explain", "auth.ts", "--root", root, "--json"]); - const response = JSON.parse(stdout) as { target: { file: string }; symbols: Array<{ name: string }>; reverseDependencies: Array<{ file: string }> }; - - expect(response.target.file).toBe("auth.ts"); - expect(response.symbols.some((symbol) => symbol.name === "validateUser")).toBeTruthy(); - expect(response.reverseDependencies.some((entry) => entry.file === "api.ts")).toBeTruthy(); -}); -``` - -- [x] **Step 5: Update exports, help, docs, and skill** - -Export from `src/index.ts`: - -```ts -export { explainCodegraphTarget } from "./agent/explain.js"; -export type { AgentExplanation, AgentExplainTarget } from "./agent/explain.js"; -``` - -Update `src/cli/help.ts`, `docs/cli.md`, `docs/library-api.md`, `docs/agent-workflows.md`, `README.md`, and `codegraph-skill/codegraph/SKILL.md`. - -- [x] **Step 6: Verify and commit** - -Run: - -```bash -npx vitest run tests/agent-explain.test.ts tests/cli-regressions.test.ts tests/review.test.ts -npm run build -npm run lint -npm run test:ci -``` - -Expected: PASS. - -Commit: - -```bash -git add src/agent/explain.ts src/cli.ts src/cli/help.ts src/index.ts tests/agent-explain.test.ts tests/cli-regressions.test.ts README.md docs/cli.md docs/library-api.md docs/agent-workflows.md codegraph-skill/codegraph/SKILL.md -git commit -m "feat: add agent explain" -``` - -## Task 4: `codegraph artifact build` - -**Files:** -- Create: `src/agent/artifact.ts` -- Test: `tests/artifact-build.test.ts` -- Modify: `src/cli.ts` -- Modify: `src/cli/help.ts` -- Modify: `src/index.ts` -- Modify: `tests/cli-regressions.test.ts` - -- [x] **Step 1: Write failing artifact tests** - -Add `tests/artifact-build.test.ts`: - -```ts -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { buildCodegraphArtifact } from "../src/agent/artifact.js"; - -describe("artifact build", () => { - it("writes sqlite, graph JSON, optional report, questions, and manifest from real project logic", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-artifact-")); - const outDir = path.join(root, "codegraph-out"); - await fs.writeFile(path.join(root, "users.sql"), "CREATE TABLE public.users (id int primary key);\n"); - await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); - await fs.writeFile(path.join(root, "api.ts"), "import { validateUser } from './auth';\nexport const ok = validateUser(1);\n"); - - const artifact = await buildCodegraphArtifact({ root, outDir, sqlite: true, graphJson: true, report: true, questions: true }); - - expect(artifact.manifestPath.endsWith("manifest.json")).toBeTruthy(); - expect(await fs.stat(path.join(outDir, "codegraph.sqlite"))).toBeTruthy(); - expect(await fs.stat(path.join(outDir, "graph.json"))).toBeTruthy(); - expect(await fs.stat(path.join(outDir, "CODEGRAPH_REPORT.md"))).toBeTruthy(); - expect(await fs.stat(path.join(outDir, "questions.json"))).toBeTruthy(); - - const manifest = JSON.parse(await fs.readFile(artifact.manifestPath, "utf8")) as { - schemaVersion: number; - artifacts: Partial>; - sql: { supported: boolean; limitation: string }; - }; - - expect(manifest.schemaVersion).toBe(1); - expect(manifest.artifacts.sqlite).toBe("codegraph.sqlite"); - expect(manifest.sql.supported).toBeTruthy(); - expect(manifest.sql.limitation).toContain("current-schema reconstruction"); - }); -}); -``` - -- [x] **Step 2: Run the failing test** - -Run: `npx vitest run tests/artifact-build.test.ts` - -Expected: FAIL because `buildCodegraphArtifact` does not exist. - -- [x] **Step 3: Implement artifact build** - -Create `src/agent/artifact.ts`. - -Implementation requirements: - -- Default output directory: `codegraph-out`. -- Default artifact names: - - `codegraph.sqlite` - - `graph.json` - - `CODEGRAPH_REPORT.md` - - `questions.json` - - `manifest.json` -- Use existing `writeGraphSqlite` for SQLite output. -- Write self-describing project-relative graph JSON (`schemaVersion: 1`, `format: "codegraph.graph-json"`), preserving top-level graph arrays for simple consumers and mirroring them under `graph`. -- Generate `CODEGRAPH_REPORT.md` deterministically from existing graph/index/search/explain data. -- Generate suggested questions from deterministic templates, such as: - - "Which files depend on ?" - - "Where is referenced?" - - "What SQL objects are related to ?" -- Do not change SQLite schema. If implementation needs new SQLite tables, add explicit migration code and a regression test in `tests/sqlite.test.ts` starting from an older schema. - -The public function signature must be: - -```ts -export type CodegraphArtifactBuildRequest = { - root: string; - outDir?: string; - sqlite?: boolean; - graphJson?: boolean; - report?: boolean; - questions?: boolean; - force?: boolean; - filterOutDir?: string; -}; - -export type CodegraphArtifactBuildResult = { - schemaVersion: 1; - root: string; - outDir: string; - manifestPath: string; - artifacts: { - sqlite?: string; - graphJson?: string; - report?: string; - questions?: string; - }; -}; - -export async function buildCodegraphArtifact(request: CodegraphArtifactBuildRequest): Promise; -``` - -- [x] **Step 4: Add CLI command and tests** - -Add: - -```bash -codegraph artifact build --root . --out codegraph-out --sqlite --json --report --questions -``` - -CLI behavior: - -- `--sqlite`, `--json`, `--report`, and `--questions` enable artifacts. -- If no artifact flags are provided, default to SQLite, graph JSON, questions, and report. -- Refuse to overwrite a non-empty output directory unless `--force` is passed. With `--force`, remove stale known Codegraph artifacts and preserve unrelated files. -- Print the manifest path in text mode and the full result in JSON mode. - -Append CLI regression: - -```ts -it("artifact build writes an agent-ready artifact bundle", async () => { - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "cg-cli-artifact-")); - const outDir = path.join(root, "out"); - await fsp.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); - - const stdout = await runCliCommand(["artifact", "build", "--root", root, "--out", outDir, "--json"]); - const result = JSON.parse(stdout) as { - manifestPath: string; - artifacts: Partial>; - }; - - expect(result.manifestPath.endsWith("manifest.json")).toBeTruthy(); - expect(result.artifacts.sqlite).toBe("codegraph.sqlite"); - expect(await fsp.stat(path.join(outDir, "manifest.json"))).toBeTruthy(); -}); -``` - -- [x] **Step 5: Update exports, help, docs, and skill** - -Export from `src/index.ts`: - -```ts -export { buildCodegraphArtifact } from "./agent/artifact.js"; -export type { CodegraphArtifactBuildRequest, CodegraphArtifactBuildResult } from "./agent/artifact.js"; -``` - -Update `src/cli/help.ts`, `docs/cli.md`, `docs/library-api.md`, `docs/agent-workflows.md`, `README.md`, and `codegraph-skill/codegraph/SKILL.md`. - -- [x] **Step 6: Verify and commit** - -Run: - -```bash -npx vitest run tests/artifact-build.test.ts tests/sqlite.test.ts tests/cli-regressions.test.ts -npm run build -npm run lint -npm run test:ci -``` - -Expected: PASS. - -Commit: - -```bash -git add src/agent/artifact.ts src/cli.ts src/cli/help.ts src/index.ts tests/artifact-build.test.ts tests/cli-regressions.test.ts README.md docs/cli.md docs/library-api.md docs/agent-workflows.md codegraph-skill/codegraph/SKILL.md -git commit -m "feat: add artifact build" -``` - -## Task 5: MCP Server - -**Files:** -- Create: `src/mcp/server.ts` -- Test: `tests/mcp-server.test.ts` -- Modify: `package.json` -- Modify: `package-lock.json` -- Modify: `src/cli.ts` -- Modify: `src/cli/help.ts` -- Modify: `src/index.ts` -- Modify: `tests/cli-regressions.test.ts` - -- [x] **Step 1: Decide dependency boundary** - -Use `@modelcontextprotocol/sdk` only if the repo does not already expose an MCP helper. Add it as a normal runtime dependency because the published CLI command needs it. - -Run: - -```bash -npm install @modelcontextprotocol/sdk -``` - -Expected: `package.json` and `package-lock.json` are updated. Do not hand-edit lockfile dependency entries. - -- [x] **Step 2: Write failing MCP handler tests** - -Add `tests/mcp-server.test.ts`: - -```ts -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { createCodegraphMcpHandlers } from "../src/mcp/server.js"; - -describe("codegraph MCP handlers", () => { - it("reuses one session across search, get_symbol, refs, and query_sqlite handlers", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-")); - await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); - await fs.writeFile(path.join(root, "api.ts"), "import { validateUser } from './auth';\nexport const ok = validateUser(1);\n"); - - const handlers = createCodegraphMcpHandlers({ root }); - const search = await handlers.search({ query: "validate user", limit: 5 }); - const first = search.results[0]; - - expect(first?.handle).toBeTruthy(); - - const symbol = await handlers.get_symbol({ handle: first.handle }); - expect(symbol.label).toContain("validateUser"); - - const refs = await handlers.refs({ handle: first.handle }); - expect(refs.references.some((ref) => ref.file === "api.ts")).toBeTruthy(); - }); - - it("keeps query_sqlite read-only", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-")); - await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); - - const handlers = createCodegraphMcpHandlers({ root }); - await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true, graphJson: true }); - - await expect(handlers.query_sqlite({ query: "DELETE FROM symbols RETURNING name;" })).rejects.toThrow(/read-only/i); - }); -}); -``` - -- [x] **Step 3: Run the failing tests** - -Run: `npx vitest run tests/mcp-server.test.ts` - -Expected: FAIL because `src/mcp/server.ts` does not exist. - -- [x] **Step 4: Implement MCP handlers independent of transport** - -Create `src/mcp/server.ts`. - -Implementation requirements: - -- Export `createCodegraphMcpHandlers(options)` for direct tests. -- Export `serveCodegraphMcp(options)` for stdio transport. -- Use `createAgentSession` internally so multiple tool calls reuse the same in-process snapshot. -- Expose these handlers: - - `search` - - `get_file` - - `get_symbol` - - `goto` - - `refs` - - `deps` - - `rdeps` - - `path` - - `impact` - - `review` - - `query_sqlite` - - `artifact_build` -- Return bounded, agent-ready responses by default. Include `limit` options for large lists. -- Ensure `query_sqlite` uses existing read-only SQL validation. -- Ensure `get_file` rejects paths outside `root`. - -Use this exported shape: - -```ts -export type CodegraphMcpServerOptions = { - root: string; - artifactPath?: string; - readOnly?: boolean; -}; - -export function createCodegraphMcpHandlers(options: CodegraphMcpServerOptions): CodegraphMcpHandlers; - -export async function serveCodegraphMcp(options: CodegraphMcpServerOptions): Promise; -``` - -- [x] **Step 5: Add CLI command** - -Add: - -```bash -codegraph mcp serve --root . --artifact codegraph-out --stdio -``` - -CLI behavior: - -- Default to stdio. -- Default to read-only tools, except `artifact_build` is allowed only when explicitly enabled by `--allow-build`. -- Never expose arbitrary shell execution. -- Use stable handles returned by `search` and `explain` for follow-up tools. - -- [x] **Step 6: Add CLI smoke test** - -Append to `tests/cli-regressions.test.ts`: - -```ts -it("mcp serve help documents read-only agent tools", async () => { - const stdout = await runCliCommand(["mcp", "serve", "--help"]); - - expect(stdout).toContain("search"); - expect(stdout).toContain("query_sqlite"); - expect(stdout).toContain("read-only"); -}); -``` - -- [x] **Step 7: Update exports, help, docs, and skill** - -Export from `src/index.ts`: - -```ts -export { createCodegraphMcpHandlers, serveCodegraphMcp } from "./mcp/server.js"; -export type { CodegraphMcpServerOptions } from "./mcp/server.js"; -``` - -Update `src/cli/help.ts`, `docs/cli.md`, `docs/library-api.md`, `docs/agent-workflows.md`, `README.md`, and `codegraph-skill/codegraph/SKILL.md`. - -- [x] **Step 8: Verify and commit** - -Run: - -```bash -npx vitest run tests/mcp-server.test.ts tests/agent-search.test.ts tests/agent-explain.test.ts tests/artifact-build.test.ts tests/cli-regressions.test.ts -npm run build -npm run lint -npm run test:ci -``` - -Expected: PASS. - -Commit: - -```bash -git add package.json package-lock.json src/mcp/server.ts src/cli.ts src/cli/help.ts src/index.ts tests/mcp-server.test.ts tests/cli-regressions.test.ts README.md docs/cli.md docs/library-api.md docs/agent-workflows.md codegraph-skill/codegraph/SKILL.md -git commit -m "feat: add MCP server" -``` - -## Task 6: Review, Performance, And Documentation Tightening - -**Files:** -- Modify: `graphify-comparison.md` -- Modify: `docs/agent-workflows.md` -- Modify: `docs/cli.md` -- Modify: `docs/library-api.md` -- Modify: `README.md` -- Modify: `codegraph-skill/codegraph/SKILL.md` -- Test: existing feature tests from Tasks 1-5 - -- [x] **Step 1: Run self-review against Graphify gap** - -Check these claims manually: - -- `codegraph search` returns ranked, explainable results with follow-ups. -- `codegraph explain` returns compact architecture context and changed-context when requested. -- `codegraph artifact build` writes the one-command useful artifact bundle. -- MCP tools reuse the same service layer and support stable handles. -- SQL extraction is not described as a remaining gap. - -- [x] **Step 2: Run performance guard tests** - -Add assertions to the existing tests rather than introducing synthetic benchmarks: - -- Search should build/load the project once per call. -- MCP search followed by refs should reuse one session. -- Artifact build should call graph/index construction once and reuse outputs for SQLite/report/questions. - -Use Vitest spies only around Codegraph-owned functions. Do not mock away indexing, graph collection, SQL facts, or CLI behavior in the primary business-logic tests. - -- [x] **Step 3: Verify public docs are concise and accurate** - -Docs must say: - -- Search is deterministic and vectorless. -- RAG integration remains optional/future unless implemented separately. -- MCP is an agent ergonomics and performance layer, not a separate analysis engine. -- SQL is supported as language input, but current-schema reconstruction is not claimed. -- SQLite query tools are read-only. - -- [x] **Step 4: Full verification** - -Run: - -```bash -npm run build -npm run lint -npm run test:ci -git diff --check -``` - -Expected: PASS. - -- [x] **Step 5: Final commit** - -Commit any review/doc/performance fixes: - -```bash -git add graphify-comparison.md README.md docs/cli.md docs/library-api.md docs/agent-workflows.md codegraph-skill/codegraph/SKILL.md tests src -git commit -m "docs: tighten agent workflow documentation" -``` - -## Final Acceptance Criteria - -- `codegraph search "auth user" --json` works on a real repo without a prebuilt artifact. -- `codegraph search "public users" --mode sql --json` returns SQL object evidence when `.sql` files define the object. -- `codegraph explain --json` returns bounded context with follow-up commands. -- `codegraph explain --changed-context --base HEAD --head WORKTREE --json` includes compact review/impact context. -- `codegraph artifact build --root . --out codegraph-out --json` writes `manifest.json`, `codegraph.sqlite`, `graph.json`, `questions.json`, and optionally `CODEGRAPH_REPORT.md`. -- `codegraph mcp serve --root . --stdio` exposes typed tools for search, file/symbol inspection, navigation, dependencies, impact/review, and read-only SQLite querying. -- MCP follow-up calls can use stable handles from search/explain results. -- No SQL extraction gap remains in docs. -- No new SQLite schema is introduced unless migration code and older-schema regression tests are included. -- `npm run build`, `npm run lint`, and `npm run test:ci` pass. diff --git a/docs/superpowers/plans/2026-05-17-read-performance.md b/docs/superpowers/plans/2026-05-17-read-performance.md deleted file mode 100644 index dbbda136..00000000 --- a/docs/superpowers/plans/2026-05-17-read-performance.md +++ /dev/null @@ -1,335 +0,0 @@ -# Common Read Performance Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make common Codegraph reads faster while preserving exact graph, navigation, search, SQLite, and CLI output behavior. - -**Architecture:** Keep the existing `ProjectIndex` as the primary in-process read model, but add reusable derived adjacency indexes so graph traversal is O(nodes + edges) instead of repeatedly scanning `graph.edges`. Reuse the manifest-backed index path for CLI graph queries where possible, and improve high-level SQLite canned traversals with indexed SQL rather than full edge loads. Do not change persistent SQLite schemas in this plan. - -**Tech Stack:** TypeScript, Node 24 `node:sqlite`, Vitest, existing Codegraph graph/indexer/session abstractions. - ---- - -## Safety Rules - -- [x] Do not change persistent SQLite schema in this branch. -- [x] Do not change public output shapes for `deps`, `rdeps`, `path`, `cycles`, `unresolved`, `search`, `explain`, or raw SQLite queries. -- [x] Preserve accuracy before speed: every optimization must compare against the existing graph semantics. -- [x] Write regression tests before production changes for each behavior change. -- [x] Before every implementation commit, run `npm run build`, `npm run lint`, and `npm run test:ci`. -- [x] After each verified implementation commit, update this checklist and commit the checked-off plan changes. - -## File Map - -- Modify `src/indexer/types.ts`: add optional derived graph read indexes to `ProjectIndex`. -- Create `src/graphs/adjacency.ts`: build and consume forward/reverse adjacency maps. -- Modify `src/graphs.ts`: export adjacency helpers if needed by CLI/session callers. -- Modify `src/indexer/build-index.ts`: attach adjacency indexes to `ProjectIndex` returns. -- Modify `src/graphs/queries.ts`: route dependency, reverse dependency, shortest path, and cycle adjacency construction through reusable helpers while preserving array-graph compatibility. -- Modify `src/cli/graphQueries.ts`: load graph queries through a manifest-backed `ProjectIndex` path instead of raw `collectGraph` where this preserves behavior. -- Modify `src/sqlite.ts`: implement high-level dependency-chain and affected-function traversals with bounded recursive SQL or indexed stepwise SQL, not full edge loading. -- Modify `src/agent/search.ts` and `src/agent/explain.ts` only if they need direct adjacency access beyond `getDependencies`/`getReverseDependencies`. -- Modify `docs/how-it-works.md` and `docs/cli.md`: document the read-performance behavior at a high level without adding new user-facing contracts. -- Add/modify tests in `tests/graph-queries.test.ts`, `tests/cli-regressions.test.ts`, `tests/sqlite.test.ts`, `tests/agent-search.test.ts`, and `tests/agent-explain.test.ts` as needed. - -## Task 1: Baseline Regression And Performance-Shape Tests - -**Files:** -- Modify: `tests/graph-queries.test.ts` -- Modify: `tests/cli-regressions.test.ts` -- Modify: `tests/sqlite.test.ts` -- Modify: `tests/agent-search.test.ts` if agent search behavior coverage needs a fixture-level guard -- Modify: `tests/agent-explain.test.ts` if explain behavior coverage needs a fixture-level guard - -- [x] **Step 1: Add graph traversal regression tests** - - Add tests that build a graph with branching and cycles, then assert: - - `getDependencies(graph, start, { depth })` returns the same files and depths as current behavior. - - `getReverseDependencies(graph, target, { depth })` returns the same files and depths as current behavior. - - `getShortestPath(graph, from, to)` returns the same path as current behavior. - - `findDetailedCycles(graph)` still ignores document-only cycles and reports code cycles. - -- [x] **Step 2: Add a performance-shape guard for traversal** - - Add a test graph where a naive implementation would scan all edges once per visited node. The test should use an instrumented graph edge iterable or helper-level instrumentation so it fails unless adjacency is built once and then reused for traversal. - -- [x] **Step 3: Add CLI graph-query cache reuse regression** - - Add or extend CLI tests so `deps`, `rdeps`, and `path` can be run after a warm index/manifest and still return the exact current JSON/text payloads. The test should prove the command path can consume `ProjectIndex.graph` rather than requiring raw `collectGraph`. - -- [x] **Step 4: Add SQLite canned traversal regression tests** - - In `tests/sqlite.test.ts`, add tests for: - - `queryGraphSqlite(..., "What is the dependency chain for class X?")` - - `queryGraphSqlite(..., "What functions are affected if module path changes?")` - - Cyclic file-edge graphs do not loop forever. - - Deleted/touched-file incremental updates still affect traversal correctly. - -- [x] **Step 5: Run focused tests and verify expected failures** - - Run: - ```powershell - npm run test:run -- tests/graph-queries.test.ts tests/cli-regressions.test.ts tests/sqlite.test.ts - ``` - Expected before implementation: the new performance-shape and/or cache-reuse tests fail for the intended reason, not because of syntax or fixture mistakes. - -## Task 2: Add Reusable Graph Adjacency Indexes - -**Files:** -- Create: `src/graphs/adjacency.ts` -- Modify: `src/graphs.ts` -- Modify: `src/indexer/types.ts` -- Modify: `src/indexer/build-index.ts` -- Test: `tests/graph-queries.test.ts` - -- [x] **Step 1: Implement adjacency helper module** - - Create `GraphAdjacencyIndex` with: - - `forward: Map` - - `reverse: Map` - - `buildGraphAdjacency(graph: Graph): GraphAdjacencyIndex` - - `getForwardNeighbors(index, file)` - - `getReverseNeighbors(index, file)` - - The helper must include only `edge.to.type === "file"` edges and must preserve existing edge order. - -- [x] **Step 2: Attach adjacency to `ProjectIndex`** - - Add an optional `graphAdjacency?: GraphAdjacencyIndex` to `ProjectIndex`. - Populate it in all `ProjectIndex` return paths in `src/indexer/build-index.ts`, including empty-index returns and manifest-backed incremental returns. - -- [x] **Step 3: Keep helper API narrow** - - Do not expose mutable adjacency maps through new public API unless needed internally. If exported from `src/graphs.ts`, export only the type and pure helper functions. - -- [x] **Step 4: Run focused tests** - - Run: - ```powershell - npm run test:run -- tests/graph-queries.test.ts - ``` - Expected after this task: adjacency construction tests pass; traversal routing tests may still fail until Task 3. - -## Task 3: Route Common In-Memory Reads Through Adjacency - -**Files:** -- Modify: `src/graphs/queries.ts` -- Modify: `src/agent/search.ts` only if direct traversal changes are needed -- Modify: `src/agent/explain.ts` only if direct traversal changes are needed -- Test: `tests/graph-queries.test.ts` -- Test: `tests/agent-search.test.ts` -- Test: `tests/agent-explain.test.ts` - -- [x] **Step 1: Update `getDependencies`** - - Use a built-once adjacency index for traversal. Preserve: - - depth behavior - - limit behavior - - result order - - start-file exclusion from results - -- [x] **Step 2: Update `getReverseDependencies`** - - Use reverse adjacency with the same behavior guarantees as `getDependencies`. - -- [x] **Step 3: Update `getShortestPath`** - - Use forward adjacency and preserve the first shortest path based on existing edge order. - -- [x] **Step 4: Reuse adjacency in cycle detection where useful** - - Keep `findDetailedCycles` output stable. If reusing the helper makes the code simpler without changing ordering, do so; otherwise leave cycle logic unchanged. - -- [x] **Step 5: Run focused tests** - - Run: - ```powershell - npm run test:run -- tests/graph-queries.test.ts tests/agent-search.test.ts tests/agent-explain.test.ts - ``` - Expected: all focused tests pass and traversal performance-shape tests prove adjacency is not rebuilt or edges are not rescanned per BFS step. - -- [x] **Step 6: Run full verification and commit** - - Run: - ```powershell - npm run build - npm run lint - npm run test:ci - ``` - Expected: all commands exit 0. - - Commit: - ```powershell - git add src tests docs/superpowers/plans/2026-05-17-read-performance.md - git commit -m "perf: index graph adjacency for common reads" - ``` - -## Task 4: Reuse Manifest-Backed Index For CLI Graph Queries - -**Files:** -- Modify: `src/cli/graphQueries.ts` -- Modify: `src/cli.ts` only if context wiring needs to pass index options -- Test: `tests/cli-regressions.test.ts` - -- [x] **Step 1: Change graph-query loading** - - Replace raw `collectGraph(...)` loading for `deps`, `rdeps`, `path`, `cycles`, and `unresolved` with an index-backed loader where command options allow it. The loaded `ProjectIndex.graph` must be behaviorally identical to the previous graph for the same scan scope and graph flags. - -- [x] **Step 2: Preserve dependency injection tests** - - `GraphQueryCommandContext` currently accepts injected `collectGraph` and `buildProjectIndex`. Keep testability by allowing injected graph/index loaders. Existing command-module tests should not need brittle filesystem fixtures. - -- [x] **Step 3: Verify no output contract changes** - - Add JSON and text assertions for `deps`, `rdeps`, and `path`. Keep path formatting unchanged. - -- [x] **Step 4: Run focused tests** - - Run: - ```powershell - npm run test:run -- tests/cli-command-modules.test.ts tests/cli-regressions.test.ts - ``` - Expected: all focused CLI tests pass. - -- [x] **Step 5: Run full verification and commit** - - Run: - ```powershell - npm run build - npm run lint - npm run test:ci - ``` - Expected: all commands exit 0. - - Commit: - ```powershell - git add src tests docs/superpowers/plans/2026-05-17-read-performance.md - git commit -m "perf: reuse indexed graphs for CLI reads" - ``` - -## Task 5: Optimize High-Level SQLite Traversal Reads - -**Files:** -- Modify: `src/sqlite.ts` -- Test: `tests/sqlite.test.ts` - -- [x] **Step 1: Replace full edge loads for dependency chain** - - Update the `dependencyChain` high-level query to walk `file_edges` through indexed `to_type = 'file'` lookups rather than calling `loadFileEdges(db, "file")` and traversing all edges in JS. - -- [x] **Step 2: Replace full edge loads for affected functions** - - Update `affectedFunctionsForModule` to use indexed reverse traversal and then query functions for impacted files. Preserve result shape and ordering semantics where existing tests assert them. - -- [x] **Step 3: Guard cycles and duplicate paths** - - Ensure traversal tracks visited files, handles cycles, dedupes results in existing order, and handles missing start modules. - -- [x] **Step 4: Run focused SQLite tests** - - Run: - ```powershell - npm run test:run -- tests/sqlite.test.ts tests/mcp-server.test.ts - ``` - Expected: all focused SQLite and MCP SQLite tests pass. - -- [x] **Step 5: Run full verification and commit** - - Run: - ```powershell - npm run build - npm run lint - npm run test:ci - ``` - Expected: all commands exit 0. - - Commit: - ```powershell - git add src tests docs/superpowers/plans/2026-05-17-read-performance.md - git commit -m "perf: use indexed SQLite traversal for graph queries" - ``` - -## Task 6: Documentation And Operator Guidance - -**Files:** -- Modify: `docs/how-it-works.md` -- Modify: `docs/cli.md` -- Modify: `codegraph-skill/codegraph/SKILL.md` only if command behavior or guidance changes -- Test: documentation covered by full build/lint/test gates - -- [x] **Step 1: Document read-performance model** - - Add concise documentation that common in-process reads use derived adjacency indexes and that CLI graph queries reuse manifest-backed index data when available. - -- [x] **Step 2: Confirm no command-surface changes** - - If no CLI flags, output fields, or commands changed, do not update `codegraph-skill/codegraph/SKILL.md`. If wording about performance-sensitive usage changes, update it consistently with `docs/cli.md`. - -- [x] **Step 3: Run full verification and commit** - - Run: - ```powershell - npm run build - npm run lint - npm run test:ci - ``` - Expected: all commands exit 0. - - Commit: - ```powershell - git add docs codegraph-skill docs/superpowers/plans/2026-05-17-read-performance.md - git commit -m "docs: describe optimized read paths" - ``` - -## Task 7: Review, Refine, Push, And Open PR - -**Files:** -- Modify as required by review findings -- Update: `docs/superpowers/plans/2026-05-17-read-performance.md` - -- [x] **Step 1: Deep review changed files** - - Review branch diff against `origin/main` for: - - accuracy regressions - - stale or incomplete tests - - avoidable API widening - - cache invalidation mistakes - - unnecessary docs churn - - TypeScript style issues, including no `any` and no `as unknown as` - -- [x] **Step 2: Run behavior comparison commands** - - Run representative commands before final push: - ```powershell - npm run build - npm run lint - npm run test:ci - node .\dist\cli.js deps src/index.ts --json - node .\dist\cli.js rdeps src/index.ts --json - node .\dist\cli.js path src/cli.ts src/index.ts --json - node .\dist\cli.js search "sqlite graph" --json - ``` - Expected: build/lint/test pass, commands return valid JSON, and no command errors. - -- [x] **Step 3: Fix all real review findings** - - If review finds issues, add or update tests first, implement fixes, rerun full verification, update the checklist, and commit. - -- [x] **Step 4: Repeat review/refine cycle** - - Repeat Steps 1-3 until review finds no real issues. - -- [x] **Step 5: Push branch** - - Run: - ```powershell - git push -u origin readspeed - ``` - -- [x] **Step 6: Open pull request** - - Use `gh pr create` if authenticated. The PR description must include: - - Summary of performance changes - - Accuracy safeguards - - Test and verification commands run - - Notes that persistent SQLite schema was not changed From f89bc8fb33d2b52fb3c92f60784279946c7fde20 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 16:32:45 -0400 Subject: [PATCH 02/25] Fix scoped inspect include roots --- REVIEW_ANALYSIS_NEXT.md | 2 +- codegraph-skill/codegraph/SKILL.md | 2 +- docs/cli.md | 2 +- src/cli.ts | 4 +- src/config.ts | 2 +- src/util/paths.ts | 21 +-- tests/cli-regressions.test.ts | 199 +++++++++++++++++++---------- tests/path-normalization.test.ts | 11 +- 8 files changed, 155 insertions(+), 88 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 6e7063d7..3795d153 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -33,7 +33,7 @@ No dependency cycles were reported. The highest remaining concentration is in: ### Correctness And Behavior -- [ ] Fix include-root discovery handling for `inspect --root . ./src`. +- [x] Fix include-root discovery handling for `inspect --root . ./src`. - Finding: `npx tsx src/cli.ts inspect --root . ./src --limit 5` currently fails with a malformed gitignore root path containing mixed POSIX and Windows separators. - Likely area: `src/cli.ts` include-root discovery setup and `src/config.ts` discovery root normalization. - Also check recommended commands from `inspect`: `unresolved` and `cycles` recommendations currently omit the include-root target suffix, which can expand a scoped inspect into whole-repo follow-ups. diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 297c4ff4..97cf15d3 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -54,7 +54,7 @@ Then choose the narrowest follow-up command: Use `--json` when the output will feed later reasoning, scripts, or another agent step. `search` is deterministic and returns project-relative explainable handles, evidence, neighbors, follow-up commands, result counts, limits, and omission counts. `explain` accepts those handles plus file paths, symbol names, and SQL object names, then returns bounded symbols, dependencies, reverse dependencies, references, snippets, SQL relation facts, changed-context review tasks/candidate tests, explicit limits, omission counts, and next commands. Generated command strings POSIX-shell-quote dynamic arguments when needed. For SQL objects, use search handles or schema-qualified names when basenames may be ambiguous. Reference and snippet omission counts are lower bounds after bounded navigation hits its cap. `artifact build` writes a durable SQLite, self-describing project-relative graph JSON, report, questions with unique stable-handle command IDs, and manifest bundle for handoff while excluding its own in-repo output directory and linked outside-root files. With `--force`, recognizable stale artifact files are removed, unrelated operator files are preserved, and unrecognized reserved-name collisions are refused. `codegraph doctor ` recognizes manifest-backed artifact bundle directories and reports expected artifact presence. `mcp serve` exposes the same primitives as read-only MCP tools by default over stdio, or over Streamable HTTP with `--port ` at `/mcp`; HTTP binds to `127.0.0.1` unless `--host ` is passed, validates Host headers, and allows loopback Host headers for wildcard binds. File/artifact paths are confined after realpath resolution, SQLite query results are row- and byte-bounded, synthetic payload functions are rejected, and `--allow-build` is required before an agent may write artifact output. -Project scans read `codegraph.config.json` from `--root` when present. Config `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even for child include-root scans. Use `discovery.ignoreGlobs` for durable repo-local excludes such as large fixtures, generated output, or vendored trees; CLI `--include-glob` and `--ignore-glob` remain additive one-off filters relative to the active scan root, and `--no-gitignore` opts out of gitignore filtering for a single command. +Project scans read `codegraph.config.json` from `--root` when present. Config `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even for child include-root scans. Use `discovery.ignoreGlobs` for durable repo-local excludes such as large fixtures, generated output, or vendored trees; CLI `--include-glob` and `--ignore-glob` remain additive one-off filters relative to the active scan root. `inspect` follow-up commands preserve the selected `--root` and include roots. `--no-gitignore` opts out of gitignore filtering for a single command. ## Tool purpose diff --git a/docs/cli.md b/docs/cli.md index d697d40d..ffc3ae32 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,7 @@ Commands that scan a project read `codegraph.config.json` from `--root` when it } ``` -`discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even when a command scans child include roots. `discovery.ignoreGlobs` is useful for large fixture, generated, or vendored folders that should not be indexed for search, unresolved-import checks, graphing, impact, or review. CLI `--include-glob` and `--ignore-glob` values are added for a single run; with child include roots, CLI globs stay relative to each scanned root. `--no-gitignore` overrides `useGitignore`. +`discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even when a command scans child include roots. `discovery.ignoreGlobs` is useful for large fixture, generated, or vendored folders that should not be indexed for search, unresolved-import checks, graphing, impact, or review. CLI `--include-glob` and `--ignore-glob` values are added for a single run; with child include roots, CLI globs stay relative to each scanned root. `inspect` follow-up commands preserve the selected `--root` and include roots. `--no-gitignore` overrides `useGitignore`. ## Core commands diff --git a/src/cli.ts b/src/cli.ts index 1cceb6cf..d946531d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -470,10 +470,10 @@ function buildRecommendedInspectCommands( `codegraph graph ${rootFlag}${targetSuffix} --json --symbols-detailed --compact-json`, ]; if (hasUnresolvedImports) { - commands.push(`codegraph unresolved ${rootFlag} --json`); + commands.push(`codegraph unresolved ${rootFlag}${targetSuffix} --json`); } if (hasCycles) { - commands.push(`codegraph cycles ${rootFlag} --sort priority --json`); + commands.push(`codegraph cycles ${rootFlag}${targetSuffix} --sort priority --json`); } commands.push(`codegraph doctor "${normalizePathForDisplay(defaultCacheIndexPath(projectRoot))}"`); return commands; diff --git a/src/config.ts b/src/config.ts index 5e42292b..8fa29c89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,7 +31,7 @@ function uniq(values: readonly string[]): string[] { } function normalizeDiscoveryRoot(root: string | undefined): string | undefined { - const normalized = root?.trim(); + const normalized = root?.trim().replace(/\\/g, "/"); return normalized ? normalized : undefined; } diff --git a/src/util/paths.ts b/src/util/paths.ts index 4649d1dd..e8f2ea0e 100644 --- a/src/util/paths.ts +++ b/src/util/paths.ts @@ -8,6 +8,11 @@ function normalizeWindowsComparablePath(filePath: string): string { return normalizePath(filePath).replace(/^([A-Za-z]):/, (_, driveLetter: string) => `${driveLetter.toUpperCase()}:`); } +function isWindowsQualifiedAbsolutePath(filePath: string): boolean { + const normalizedPath = normalizePath(filePath); + return /^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("//"); +} + export function isAbsoluteFilePath(filePath: string): boolean { return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); } @@ -16,7 +21,7 @@ export function resolveFilePathFromRoot(projectRoot: string, filePath: string): if (isAbsoluteFilePath(filePath)) { return filePath; } - if (path.win32.isAbsolute(projectRoot)) { + if (isWindowsQualifiedAbsolutePath(projectRoot)) { return path.win32.resolve(projectRoot, filePath); } return path.resolve(projectRoot, filePath); @@ -30,14 +35,12 @@ function resolveComparableProjectRoot(projectRoot: string): string { } function isRelativeToRoot(normalizedRoot: string, normalizedFile: string): boolean { - const comparableRoot = path.win32.isAbsolute(normalizedRoot) - ? normalizeWindowsComparablePath(normalizedRoot) - : normalizedRoot; - const comparableFile = path.win32.isAbsolute(normalizedFile) - ? normalizeWindowsComparablePath(normalizedFile) - : normalizedFile; + const rootIsWindowsPath = isWindowsQualifiedAbsolutePath(normalizedRoot); + const fileIsWindowsPath = isWindowsQualifiedAbsolutePath(normalizedFile); + const comparableRoot = rootIsWindowsPath ? normalizeWindowsComparablePath(normalizedRoot) : normalizedRoot; + const comparableFile = fileIsWindowsPath ? normalizeWindowsComparablePath(normalizedFile) : normalizedFile; - if (path.win32.isAbsolute(comparableRoot) && path.win32.isAbsolute(comparableFile)) { + if (rootIsWindowsPath && fileIsWindowsPath) { if (comparableFile === comparableRoot) { return true; } @@ -73,7 +76,7 @@ export function toProjectRelativePath(projectRoot: string, filePath: string): st if (!isFilePathWithinRoot(normalizedRoot, normalizedFile)) { return null; } - if (path.win32.isAbsolute(normalizedRoot) && path.win32.isAbsolute(normalizedFile)) { + if (isWindowsQualifiedAbsolutePath(normalizedRoot) && isWindowsQualifiedAbsolutePath(normalizedFile)) { const comparableRoot = normalizeWindowsComparablePath(normalizedRoot); const comparableFile = normalizeWindowsComparablePath(normalizedFile); return normalizePath(path.win32.relative(comparableRoot, comparableFile)); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index d7714712..ea957a00 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -829,11 +829,62 @@ describe("CLI regressions", () => { expect(report.recommendedCommands).toContain( `codegraph hotspots --root "${normalize(tmpDir)}" "${normalize(srcDir)}" --limit 20 --json`, ); + expect(report.recommendedCommands).toContain( + `codegraph graph --root "${normalize(tmpDir)}" "${normalize(srcDir)}" --json --symbols-detailed --compact-json`, + ); expect(report.recommendedCommands).toContain( `codegraph doctor "${normalize(path.join(tmpDir, ".codegraph-cache", "index-v1"))}"`, ); }); + it("inspect supports relative --root include roots with project-root config ignores", async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-inspect-relative-root-")); + const srcDir = path.join(tmpDir, "src"); + await fsp.mkdir(srcDir, { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "codegraph.config.json"), + JSON.stringify({ discovery: { ignoreGlobs: ["src/ignored.ts"] } }), + "utf8", + ); + await fsp.writeFile( + path.join(srcDir, "a.ts"), + "import { b } from './b';\nimport { missing } from './missing';\nexport const a = b + missing;\n", + "utf8", + ); + await fsp.writeFile(path.join(srcDir, "b.ts"), "import { a } from './a';\nexport const b = a;\n", "utf8"); + await fsp.writeFile(path.join(srcDir, "ignored.ts"), "export const ignored = 1;\n", "utf8"); + + const { stdout } = await runCliCommandDetailed( + ["inspect", "--root", ".", "./src", "--limit", "5"], + undefined, + tmpDir, + ); + const report = JSON.parse(stdout) as { + root: string; + includeRoots: string[]; + files: { + total: number; + byLanguage: Record; + }; + unresolved: { total: number }; + cycles: { total: number }; + recommendedCommands: string[]; + }; + + expect(report.root).toBe(normalize(tmpDir)); + expect(report.includeRoots).toEqual([normalize(srcDir)]); + expect(report.files.total).toBe(2); + expect(report.files.byLanguage.ts).toBe(2); + expect(report.unresolved.total).toBe(1); + expect(report.cycles.total).toBe(1); + expect(report.recommendedCommands).toContain( + `codegraph unresolved --root "${normalize(tmpDir)}" "${normalize(srcDir)}" --json`, + ); + expect(report.recommendedCommands).toContain( + `codegraph cycles --root "${normalize(tmpDir)}" "${normalize(srcDir)}" --sort priority --json`, + ); + }); + it("unresolved filters declared dependencies for scoped roots", async () => { const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-unresolved-scoped-")); try { @@ -969,43 +1020,47 @@ describe("CLI regressions", () => { expect(installedSkill).toContain("name: codegraph"); }); - it("skill install supports all agent defaults", async () => { - const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-skill-matrix-")); - const env = { - HOME: tmpDir, - USERPROFILE: tmpDir, - CODEX_HOME: "", - }; - const cases = [ - { agent: "agents", targetDir: path.join(tmpDir, ".agents", "skills", "codegraph") }, - { agent: "claude", targetDir: path.join(tmpDir, ".claude", "skills", "codegraph") }, - { agent: "codex", targetDir: path.join(tmpDir, ".codex", "skills", "codegraph") }, - { agent: "cursor", targetDir: path.join(tmpDir, ".cursor", "skills", "codegraph") }, - { agent: "gemini", targetDir: path.join(tmpDir, ".gemini", "skills", "codegraph") }, - { agent: "opencode", targetDir: path.join(tmpDir, ".config", "opencode", "skills", "codegraph") }, - ] as const; - - for (const entry of cases) { - await fsp.mkdir(path.dirname(entry.targetDir), { recursive: true }); - const result = await runCliCommandDetailed( - ["skill", "install", "--agent", entry.agent], - undefined, - process.cwd(), - env, - ); - const payload = JSON.parse(result.stdout) as { - agent: string; - installed: boolean; - skillFilePath: string; - targetDir: string; + it( + "skill install supports all agent defaults", + async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-skill-matrix-")); + const env = { + HOME: tmpDir, + USERPROFILE: tmpDir, + CODEX_HOME: "", }; + const cases = [ + { agent: "agents", targetDir: path.join(tmpDir, ".agents", "skills", "codegraph") }, + { agent: "claude", targetDir: path.join(tmpDir, ".claude", "skills", "codegraph") }, + { agent: "codex", targetDir: path.join(tmpDir, ".codex", "skills", "codegraph") }, + { agent: "cursor", targetDir: path.join(tmpDir, ".cursor", "skills", "codegraph") }, + { agent: "gemini", targetDir: path.join(tmpDir, ".gemini", "skills", "codegraph") }, + { agent: "opencode", targetDir: path.join(tmpDir, ".config", "opencode", "skills", "codegraph") }, + ] as const; + + for (const entry of cases) { + await fsp.mkdir(path.dirname(entry.targetDir), { recursive: true }); + const result = await runCliCommandDetailed( + ["skill", "install", "--agent", entry.agent], + undefined, + process.cwd(), + env, + ); + const payload = JSON.parse(result.stdout) as { + agent: string; + installed: boolean; + skillFilePath: string; + targetDir: string; + }; - expect(payload.agent).toBe(entry.agent); - expect(payload.installed).toBe(true); - expect(normalize(payload.targetDir)).toBe(normalize(entry.targetDir)); - expect(normalize(payload.skillFilePath)).toBe(normalize(path.join(entry.targetDir, "SKILL.md"))); - } - }, slowCliMatrixTimeoutMs); + expect(payload.agent).toBe(entry.agent); + expect(payload.installed).toBe(true); + expect(normalize(payload.targetDir)).toBe(normalize(entry.targetDir)); + expect(normalize(payload.skillFilePath)).toBe(normalize(path.join(entry.targetDir, "SKILL.md"))); + } + }, + slowCliMatrixTimeoutMs, + ); it("skill install copies the bundled skill into the Cursor skills directory", async () => { const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-skill-cursor-")); @@ -1027,43 +1082,47 @@ describe("CLI regressions", () => { expect(skill).toContain("name: codegraph"); }); - it("skill doctor reports agent-specific default targets", async () => { - const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-skill-doctor-agent-")); - const env = { - HOME: tmpDir, - USERPROFILE: tmpDir, - CODEX_HOME: "", - }; - const cases = [ - { agent: "agents", targetDir: path.join(tmpDir, ".agents", "skills", "codegraph") }, - { agent: "claude", targetDir: path.join(tmpDir, ".claude", "skills", "codegraph") }, - { agent: "codex", targetDir: path.join(tmpDir, ".codex", "skills", "codegraph") }, - { agent: "cursor", targetDir: path.join(tmpDir, ".cursor", "skills", "codegraph") }, - { agent: "gemini", targetDir: path.join(tmpDir, ".gemini", "skills", "codegraph") }, - { agent: "opencode", targetDir: path.join(tmpDir, ".config", "opencode", "skills", "codegraph") }, - ] as const; - - for (const entry of cases) { - await fsp.mkdir(path.dirname(entry.targetDir), { recursive: true }); - const result = await runCliCommandDetailed( - ["skill", "doctor", "--agent", entry.agent], - undefined, - process.cwd(), - env, - ); - const report = JSON.parse(result.stdout) as { - agent?: string; - defaultTargetDir: string; - installTargetDir: string; - requestedTargetDir?: string; + it( + "skill doctor reports agent-specific default targets", + async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-skill-doctor-agent-")); + const env = { + HOME: tmpDir, + USERPROFILE: tmpDir, + CODEX_HOME: "", }; + const cases = [ + { agent: "agents", targetDir: path.join(tmpDir, ".agents", "skills", "codegraph") }, + { agent: "claude", targetDir: path.join(tmpDir, ".claude", "skills", "codegraph") }, + { agent: "codex", targetDir: path.join(tmpDir, ".codex", "skills", "codegraph") }, + { agent: "cursor", targetDir: path.join(tmpDir, ".cursor", "skills", "codegraph") }, + { agent: "gemini", targetDir: path.join(tmpDir, ".gemini", "skills", "codegraph") }, + { agent: "opencode", targetDir: path.join(tmpDir, ".config", "opencode", "skills", "codegraph") }, + ] as const; + + for (const entry of cases) { + await fsp.mkdir(path.dirname(entry.targetDir), { recursive: true }); + const result = await runCliCommandDetailed( + ["skill", "doctor", "--agent", entry.agent], + undefined, + process.cwd(), + env, + ); + const report = JSON.parse(result.stdout) as { + agent?: string; + defaultTargetDir: string; + installTargetDir: string; + requestedTargetDir?: string; + }; - expect(report.agent).toBe(entry.agent); - expect(normalize(report.defaultTargetDir)).toBe(normalize(entry.targetDir)); - expect(normalize(report.installTargetDir)).toBe(normalize(entry.targetDir)); - expect(report.requestedTargetDir).toBeUndefined(); - } - }, slowCliMatrixTimeoutMs); + expect(report.agent).toBe(entry.agent); + expect(normalize(report.defaultTargetDir)).toBe(normalize(entry.targetDir)); + expect(normalize(report.installTargetDir)).toBe(normalize(entry.targetDir)); + expect(report.requestedTargetDir).toBeUndefined(); + } + }, + slowCliMatrixTimeoutMs, + ); it("skill install --force replaces stale files in the target directory", async () => { const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-skill-force-")); diff --git a/tests/path-normalization.test.ts b/tests/path-normalization.test.ts index 8c93206f..5cfa2588 100644 --- a/tests/path-normalization.test.ts +++ b/tests/path-normalization.test.ts @@ -21,6 +21,13 @@ describe("cross-platform path normalization", () => { expect(resolveFilePathFromRoot("/workspace/codegraph", windowsBackslashPath)).toBe(windowsBackslashPath); }); + it("resolves relative paths against POSIX absolute roots on any host OS", () => { + const root = "/mnt/e/git repos/codegraph"; + + expect(resolveFilePathFromRoot(root, ".")).toBe(root); + expect(resolveFilePathFromRoot(root, "./src")).toBe("/mnt/e/git repos/codegraph/src"); + }); + it("normalizes impact paths without re-rooting Windows-style absolute inputs", () => { expect(normalizeImpactFilePath("/workspace/codegraph", "C:/repo/src/main.ts")).toBe("C:/repo/src/main.ts"); expect(normalizeImpactFilePath("/workspace/codegraph", String.raw`C:\repo\src\main.ts`)).toBe( @@ -61,9 +68,7 @@ describe("cross-platform path normalization", () => { const root = "C:/workspace/codegraph"; expect(assertFilePathWithinRoot(root, "src/main.ts", "Input")).toBe("C:/workspace/codegraph/src/main.ts"); - expect(() => assertFilePathWithinRoot(root, "../outside.ts", "Input")).toThrow( - "Input is outside project root", - ); + expect(() => assertFilePathWithinRoot(root, "../outside.ts", "Input")).toThrow("Input is outside project root"); }); it("normalizes resolution hints by trimming, slash-normalizing, and deduping", () => { From e4cddc81233a246d7dfc154fc6448acdd464072d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 16:45:20 -0400 Subject: [PATCH 03/25] Standardize CLI numeric parsing --- REVIEW_ANALYSIS_NEXT.md | 2 +- codegraph-skill/codegraph/SKILL.md | 2 + docs/cli.md | 2 + src/cli.ts | 27 +++++---- src/cli/chunk.ts | 10 +++- src/cli/graphDelta.ts | 4 +- src/cli/graphQueries.ts | 17 ++++-- src/cli/impact.ts | 43 +++++++++------ src/cli/mcp.ts | 12 +--- src/cli/options.ts | 88 ++++++++++++++++++++++++++++-- src/cli/review.ts | 8 +-- tests/cli-command-modules.test.ts | 30 ++++++++++ tests/cli-regressions.test.ts | 26 +++++++++ tests/impact-cli.test.ts | 6 ++ 14 files changed, 223 insertions(+), 54 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 3795d153..2501ca06 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -39,7 +39,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Also check recommended commands from `inspect`: `unresolved` and `cycles` recommendations currently omit the include-root target suffix, which can expand a scoped inspect into whole-repo follow-ups. - Add tests in `tests/cli-regressions.test.ts` for `inspect --root . ./src`, include-root recommendations, and config `ignoreGlobs` with include roots. -- [ ] Standardize CLI numeric option parsing and validation. +- [x] Standardize CLI numeric option parsing and validation. - Finding: several commands still use raw `Number(...)` for user input: `--threads`, `--symbols-detailed-max-edges`, `--max-hits`, `--max-callsites`, `--max-tests`, chunk token bounds, graph query depth, and impact options. - Risk: invalid values can silently become `NaN`, `0`, or undefined depending on downstream code. - Add shared helpers in `src/cli/options.ts` for positive integers, non-negative integers, optional integers, and bounded integers. diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 97cf15d3..8900fc21 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -54,6 +54,8 @@ Then choose the narrowest follow-up command: Use `--json` when the output will feed later reasoning, scripts, or another agent step. `search` is deterministic and returns project-relative explainable handles, evidence, neighbors, follow-up commands, result counts, limits, and omission counts. `explain` accepts those handles plus file paths, symbol names, and SQL object names, then returns bounded symbols, dependencies, reverse dependencies, references, snippets, SQL relation facts, changed-context review tasks/candidate tests, explicit limits, omission counts, and next commands. Generated command strings POSIX-shell-quote dynamic arguments when needed. For SQL objects, use search handles or schema-qualified names when basenames may be ambiguous. Reference and snippet omission counts are lower bounds after bounded navigation hits its cap. `artifact build` writes a durable SQLite, self-describing project-relative graph JSON, report, questions with unique stable-handle command IDs, and manifest bundle for handoff while excluding its own in-repo output directory and linked outside-root files. With `--force`, recognizable stale artifact files are removed, unrelated operator files are preserved, and unrecognized reserved-name collisions are refused. `codegraph doctor ` recognizes manifest-backed artifact bundle directories and reports expected artifact presence. `mcp serve` exposes the same primitives as read-only MCP tools by default over stdio, or over Streamable HTTP with `--port ` at `/mcp`; HTTP binds to `127.0.0.1` unless `--host ` is passed, validates Host headers, and allows loopback Host headers for wildcard binds. File/artifact paths are confined after realpath resolution, SQLite query results are row- and byte-bounded, synthetic payload functions are rejected, and `--allow-build` is required before an agent may write artifact output. +Numeric options such as `--limit`, `--threads`, `--depth`, `--max-refs`, and token bounds must be integers in their documented ranges; invalid numeric values fail instead of being silently clamped or ignored. + Project scans read `codegraph.config.json` from `--root` when present. Config `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even for child include-root scans. Use `discovery.ignoreGlobs` for durable repo-local excludes such as large fixtures, generated output, or vendored trees; CLI `--include-glob` and `--ignore-glob` remain additive one-off filters relative to the active scan root. `inspect` follow-up commands preserve the selected `--root` and include roots. `--no-gitignore` opts out of gitignore filtering for a single command. ## Tool purpose diff --git a/docs/cli.md b/docs/cli.md index ffc3ae32..f0d3dd69 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,6 +8,8 @@ If the CLI is not installed yet, use the install paths in [docs/installation.md] Bare `codegraph graph` writes `codegraph.json` and `codegraph.err` in the current directory. Use `--stdout`, `--output `, or an explicit format flag such as `--json` when scripting. +Numeric options such as `--limit`, `--threads`, `--depth`, `--max-refs`, and token bounds must be integers in their documented ranges; invalid numeric values fail instead of being silently clamped or ignored. + ## Runtime selection The CLI defaults to `--native auto`, which uses the native Tree-sitter path when a compatible native artifact is available and falls back automatically otherwise. diff --git a/src/cli.ts b/src/cli.ts index d946531d..6c3c7b4d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -51,7 +51,14 @@ import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { CLI_HELP_TEXT, helpTextForCommand, isKnownCliCommand } from "./cli/help.js"; import { handleImpactCommand } from "./cli/impact.js"; import { handleMcpServeCommand } from "./cli/mcp.js"; -import { isCliValueOption, parseCacheModeOption, parsePositiveIntegerOption } from "./cli/options.js"; +import { + isCliValueOption, + parseCacheModeOption, + parseNonNegativeIntegerOption, + parseOptionalNonNegativeIntegerOption, + parseOptionalPositiveIntegerOption, + parsePositiveIntegerOption, +} from "./cli/options.js"; import { getCodegraphPackageIdentity, getCodegraphVersion } from "./cli/packageInfo.js"; import { handleReviewCommand } from "./cli/review.js"; import { handleSearchCommand } from "./cli/search.js"; @@ -1173,7 +1180,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { const wantSymbols = hasExplicitSymbolFlag; const detailedSymbols = hasFlag("--symbols-detailed"); - const threads = Number(getOpt("--threads") ?? 0); + const threads = parseNonNegativeIntegerOption(getOpt("--threads"), "--threads", 0); const cache = parseCacheModeOption(getOpt("--cache")); const cacheStrict = hasFlag("--cache-strict"); const stable = hasFlag("--stable"); @@ -1263,7 +1270,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { const detailedSymbols = hasFlag("--symbols-detailed"); const scope = getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined; const maxEdgesRaw = getOpt("--symbols-detailed-max-edges"); - const maxEdges = maxEdgesRaw !== undefined ? Number(maxEdgesRaw) : undefined; + const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges"); const membersOnly = hasFlag("--symbols-detailed-members-only"); const sgraph = detailedSymbols ? await buildSymbolGraphDetailed(index, { @@ -1315,7 +1322,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { if (detailedSymbols) { const scope = getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined; const maxEdgesRaw = getOpt("--symbols-detailed-max-edges"); - const maxEdges = maxEdgesRaw !== undefined ? Number(maxEdgesRaw) : undefined; + const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges"); const membersOnly = hasFlag("--symbols-detailed-members-only"); sgraph = await buildSymbolGraphDetailed(index, { ...(scope !== undefined ? { scope } : {}), @@ -1408,7 +1415,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { if (commandReport) { commandReport.timings.resolveFilesMs = Math.round(performance.now() - resolveStart); } - const threads = Number(getOpt("--threads") ?? 0); + const threads = parseNonNegativeIntegerOption(getOpt("--threads"), "--threads", 0); const cache = parseCacheModeOption(getOpt("--cache")); const cacheStrict = hasFlag("--cache-strict"); const full = hasFlag("--json") || hasFlag("--full"); @@ -1541,8 +1548,8 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return; } const file = resolvedFile.file; - const line = Number(lineArg); - const column = Number(colArg); + const line = parsePositiveIntegerOption(lineArg, "line", 1); + const column = parsePositiveIntegerOption(colArg, "column", 1); const index = await buildProjectIndex(projectRootFs, { onProgress: progressHandler, discovery: discoveryOptions, @@ -1562,8 +1569,8 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { writeStderrLine("Usage: refs --file --line --col "); exitCli(2); } - const line = Number(lineArg); - const column = Number(colArg); + const line = parsePositiveIntegerOption(lineArg, "--line", 1); + const column = parsePositiveIntegerOption(colArg, "--col", 1); const pretty = hasFlag("--pretty"); const resolvedFile = resolveCliProjectFile(projectRootFs, fileArg, "File"); if (resolvedFile.status === "error") { @@ -1615,7 +1622,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { const ignoreCase = hasFlag("--ignore-case") || hasFlag("-i"); const maxHitsRaw = getOpt("--max-hits"); - const maxHits = maxHitsRaw !== undefined ? Number(maxHitsRaw) : undefined; + const maxHits = parseOptionalPositiveIntegerOption(maxHitsRaw, "--max-hits"); const hits = await textGrep(projectRootFs, patternSource!, patterns, { ignoreCase, ...(maxHits !== undefined ? { maxHits } : {}), diff --git a/src/cli/chunk.ts b/src/cli/chunk.ts index 4e7c959a..7fe510c1 100644 --- a/src/cli/chunk.ts +++ b/src/cli/chunk.ts @@ -5,6 +5,7 @@ import { chunkFile } from "../chunking/chunkFile.js"; import { chunkSFCFile } from "../chunking/chunkSFC.js"; import { chunkTextFile } from "../chunking/chunkTextFile.js"; import { supportForFile } from "../languages.js"; +import { parsePositiveIntegerOption } from "./options.js"; const chunkLanguageAliases: Record = { js: "javascript", @@ -63,8 +64,13 @@ export async function handleChunkCommand(context: ChunkCommandContext): Promise< const forceText = context.hasFlag("--text"); const minTokensRaw = context.getOpt("--min-tokens"); const maxTokensRaw = context.getOpt("--max-tokens"); - const minTokens = minTokensRaw !== undefined ? Number(minTokensRaw) : 150; - const maxTokens = maxTokensRaw !== undefined ? Number(maxTokensRaw) : 400; + const minTokens = parsePositiveIntegerOption(minTokensRaw, "--min-tokens", 150); + const maxTokens = parsePositiveIntegerOption(maxTokensRaw, "--max-tokens", 400); + if (maxTokens < minTokens) { + throw new Error( + `Invalid --max-tokens value "${maxTokens}". Expected a value greater than or equal to --min-tokens.`, + ); + } const isSFC = languageId === "vue" || languageId === "svelte"; if (forceText || (!isSFC && !LANG_CONFIGS[languageId])) { diff --git a/src/cli/graphDelta.ts b/src/cli/graphDelta.ts index d28ba27c..a387c5fa 100644 --- a/src/cli/graphDelta.ts +++ b/src/cli/graphDelta.ts @@ -3,7 +3,7 @@ import { buildGraphDelta, type IncrementalBuildOptions } from "../indexer.js"; import type { GraphBuildOptions } from "../graphs.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; import { normalizePath, resolveFilePathFromRoot } from "../util.js"; -import { parseCacheModeOption } from "./options.js"; +import { parseCacheModeOption, parseNonNegativeIntegerOption } from "./options.js"; export type GraphDeltaCommandContext = { projectRootFs: string; @@ -21,7 +21,7 @@ export type GraphDeltaCommandContext = { }; export async function handleGraphDeltaCommand(context: GraphDeltaCommandContext): Promise { - const threads = Number(context.getOpt("--threads") ?? 0); + const threads = parseNonNegativeIntegerOption(context.getOpt("--threads"), "--threads", 0); const cache = parseCacheModeOption(context.getOpt("--cache")); const cacheStrict = context.hasFlag("--cache-strict"); const cacheVerify = context.hasFlag("--cache-verify"); diff --git a/src/cli/graphQueries.ts b/src/cli/graphQueries.ts index 579b1107..eba2b216 100644 --- a/src/cli/graphQueries.ts +++ b/src/cli/graphQueries.ts @@ -18,6 +18,7 @@ import { } from "../graphs.js"; import type { Graph } from "../types.js"; import { assertFilePathWithinRoot } from "../util.js"; +import { parseOptionalNonNegativeIntegerOption } from "./options.js"; export type GraphQueryCommand = "deps" | "rdeps" | "path" | "cycles" | "unresolved" | "apisurface"; @@ -77,7 +78,11 @@ function writeCliProjectFileError( async function loadGraph(context: GraphQueryCommandContext): Promise { if (context.collectGraph) { - const graph = await context.collectGraph(context.projectRootFs, await context.listProjectFilesForScan(), context.graphOptions); + const graph = await context.collectGraph( + context.projectRootFs, + await context.listProjectFilesForScan(), + context.graphOptions, + ); return { graph }; } const buildProjectIndex = context.buildProjectIndex ?? defaultBuildProjectIndex; @@ -95,8 +100,10 @@ async function handleDepsCommand(context: GraphQueryCommandContext): Promise path.relative(context.projectRootFs, entry)).join(" -> ")} -> ...`); + context.writeStdoutLine( + ` ${cycle.files.map((entry) => path.relative(context.projectRootFs, entry)).join(" -> ")} -> ...`, + ); if (cycle.entryEdges.length) { context.writeStdoutLine(" Incoming edges:"); for (const edge of cycle.entryEdges) { diff --git a/src/cli/impact.ts b/src/cli/impact.ts index d8b95bc7..966a4155 100644 --- a/src/cli/impact.ts +++ b/src/cli/impact.ts @@ -8,11 +8,21 @@ import { type ImpactOptions, type ImpactReport, } from "../impact/index.js"; -import { graphToMermaidSymbolsWithFiles, type GraphBuildOptions, type SymbolGraph, type SymbolNodeKind } from "../graphs.js"; +import { + graphToMermaidSymbolsWithFiles, + type GraphBuildOptions, + type SymbolGraph, + type SymbolNodeKind, +} from "../graphs.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; import type { Graph } from "../types.js"; import type { ProjectFileDiscoveryOptions } from "../util.js"; -import { parseCacheModeOption } from "./options.js"; +import { + parseCacheModeOption, + parseOptionalNonNegativeIntegerOption, + parseOptionalPositiveIntegerOption, + parsePositiveIntegerOption, +} from "./options.js"; type ImpactOptionsBuilder = Partial & { base?: string; @@ -241,10 +251,7 @@ function buildDiffProviderOptions(context: ImpactCommandContext): ImpactOptionsB return { provider }; } -async function hydrateDiffProviderOptions( - context: ImpactCommandContext, - options: ImpactOptionsBuilder, -): Promise { +async function hydrateDiffProviderOptions(context: ImpactCommandContext, options: ImpactOptionsBuilder): Promise { if (options.provider === "git") { const base = context.getOpt("--base"); const head = context.getOpt("--head"); @@ -267,10 +274,7 @@ async function hydrateDiffProviderOptions( "Impact provider 'github' requires --repo owner/name and --pr . Example: codegraph impact --provider github --repo acme/app --pr 42", ); } - options.pr = Number(pr); - if (!Number.isFinite(options.pr) || options.pr <= 0) { - throw new Error("Impact provider 'github' expects --pr as a positive integer."); - } + options.pr = parsePositiveIntegerOption(pr, "--pr", 1); options.repo = repo; return; } @@ -280,8 +284,8 @@ async function hydrateDiffProviderOptions( function applyAnalysisOptions(context: ImpactCommandContext, options: ImpactOptionsBuilder): void { const threadsRaw = context.getOpt("--threads"); - const threads = threadsRaw ? Number(threadsRaw) : 0; - if (threadsRaw) options.threads = threads; + const threads = parseOptionalNonNegativeIntegerOption(threadsRaw, "--threads"); + if (threads !== undefined) options.threads = threads; const cache = parseCacheModeOption(context.getOpt("--cache")); if (cache !== undefined) options.cache = cache; @@ -290,10 +294,12 @@ function applyAnalysisOptions(context: ImpactCommandContext, options: ImpactOpti if (context.hasFlag("--compact") || context.hasFlag("--compact-json")) options.compact = true; const maxRefs = context.getOpt("--max-refs"); - if (maxRefs) options.maxRefs = Number(maxRefs); + const parsedMaxRefs = parseOptionalNonNegativeIntegerOption(maxRefs, "--max-refs"); + if (parsedMaxRefs !== undefined) options.maxRefs = parsedMaxRefs; const depth = context.getOpt("--depth"); - if (depth) options.depth = Number(depth); + const parsedDepth = parseOptionalNonNegativeIntegerOption(depth, "--depth"); + if (parsedDepth !== undefined) options.depth = parsedDepth; const scope = context.getOpt("--scope"); if (scope === "all" || scope === "imported") options.scope = scope; @@ -302,10 +308,12 @@ function applyAnalysisOptions(context: ImpactCommandContext, options: ImpactOpti if (refContext) options.refContext = refContext as "line" | "block"; const refContextLines = context.getOpt("--ref-context-lines"); - if (refContextLines) options.refContextLines = Number(refContextLines); + const parsedRefContextLines = parseOptionalNonNegativeIntegerOption(refContextLines, "--ref-context-lines"); + if (parsedRefContextLines !== undefined) options.refContextLines = parsedRefContextLines; const refBlockMaxLines = context.getOpt("--ref-block-max-lines"); - if (refBlockMaxLines) options.refBlockMaxLines = Number(refBlockMaxLines); + const parsedRefBlockMaxLines = parseOptionalPositiveIntegerOption(refBlockMaxLines, "--ref-block-max-lines"); + if (parsedRefBlockMaxLines !== undefined) options.refBlockMaxLines = parsedRefBlockMaxLines; if (context.discoveryOptions.ignoreGlobs?.length) { options.ignoreGlobs = context.discoveryOptions.ignoreGlobs; @@ -336,7 +344,8 @@ function applyAnalysisOptions(context: ImpactCommandContext, options: ImpactOpti } function buildIndexOptions(context: ImpactCommandContext, options: ImpactOptionsBuilder): BuildOptions { - const cacheMode = options.cache === "off" || options.cache === "memory" || options.cache === "disk" ? options.cache : undefined; + const cacheMode = + options.cache === "off" || options.cache === "memory" || options.cache === "disk" ? options.cache : undefined; const indexOpts: BuildOptions = { threads: options.threads ?? 0, discovery: context.discoveryOptions, diff --git a/src/cli/mcp.ts b/src/cli/mcp.ts index fb020398..b349bb4e 100644 --- a/src/cli/mcp.ts +++ b/src/cli/mcp.ts @@ -1,5 +1,6 @@ import { serveCodegraphMcp } from "../mcp/server.js"; import { MCP_HELP_TEXT } from "./help.js"; +import { parseOptionalBoundedIntegerOption } from "./options.js"; export type McpServeCommandContext = { positionals: string[]; @@ -10,15 +11,6 @@ export type McpServeCommandContext = { exit: (code: number) => never; }; -function parsePortOption(rawValue: string | undefined): number | undefined { - if (rawValue === undefined) return undefined; - const parsedValue = Number(rawValue); - if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 65535) { - throw new Error(`Invalid --port value "${rawValue}". Expected an integer from 0 to 65535.`); - } - return parsedValue; -} - export async function handleMcpServeCommand(context: McpServeCommandContext): Promise { const mcpCommand = context.positionals[0]; if (mcpCommand !== "serve") { @@ -29,7 +21,7 @@ export async function handleMcpServeCommand(context: McpServeCommandContext): Pr const artifactPath = context.getOpt("--artifact"); let port: number | undefined; try { - port = parsePortOption(context.getOpt("--port")); + port = parseOptionalBoundedIntegerOption(context.getOpt("--port"), "--port", 0, 65535); } catch (error) { context.writeStderrLine(error instanceof Error ? error.message : String(error)); context.exit(2); diff --git a/src/cli/options.ts b/src/cli/options.ts index 7969de76..dee22c77 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -77,6 +77,22 @@ export function parseCacheModeOption(rawValue: string | undefined): CacheModeOpt throw new Error(`Invalid --cache value "${rawValue}". Expected one of: off, memory, disk.`); } +function parseIntegerOptionValue( + rawValue: string, + optionName: string, + expectedDescription: string, + minValue: number, + maxValue?: number, +): number { + const parsedValue = Number(rawValue); + const isAboveMinimum = parsedValue >= minValue; + const isBelowMaximum = maxValue === undefined || parsedValue <= maxValue; + if (!Number.isInteger(parsedValue) || !isAboveMinimum || !isBelowMaximum) { + throw new Error(`Invalid ${optionName} value "${rawValue}". Expected ${expectedDescription}.`); + } + return parsedValue; +} + export function parsePositiveIntegerOption( rawValue: string | undefined, optionName: string, @@ -85,9 +101,73 @@ export function parsePositiveIntegerOption( if (rawValue === undefined) { return defaultValue; } - const parsedValue = Number(rawValue); - if (!Number.isInteger(parsedValue) || parsedValue < 1) { - throw new Error(`Invalid ${optionName} value "${rawValue}". Expected a positive integer.`); + return parseIntegerOptionValue(rawValue, optionName, "a positive integer", 1); +} + +export function parseOptionalPositiveIntegerOption( + rawValue: string | undefined, + optionName: string, +): number | undefined { + if (rawValue === undefined) { + return undefined; } - return parsedValue; + return parseIntegerOptionValue(rawValue, optionName, "a positive integer", 1); +} + +export function parseNonNegativeIntegerOption( + rawValue: string | undefined, + optionName: string, + defaultValue: number, +): number { + if (rawValue === undefined) { + return defaultValue; + } + return parseIntegerOptionValue(rawValue, optionName, "a non-negative integer", 0); +} + +export function parseOptionalNonNegativeIntegerOption( + rawValue: string | undefined, + optionName: string, +): number | undefined { + if (rawValue === undefined) { + return undefined; + } + return parseIntegerOptionValue(rawValue, optionName, "a non-negative integer", 0); +} + +export function parseBoundedIntegerOption( + rawValue: string | undefined, + optionName: string, + defaultValue: number, + minValue: number, + maxValue: number, +): number { + if (rawValue === undefined) { + return defaultValue; + } + return parseIntegerOptionValue( + rawValue, + optionName, + `an integer from ${minValue} to ${maxValue}`, + minValue, + maxValue, + ); +} + +export function parseOptionalBoundedIntegerOption( + rawValue: string | undefined, + optionName: string, + minValue: number, + maxValue: number, +): number | undefined { + if (rawValue === undefined) { + return undefined; + } + return parseIntegerOptionValue( + rawValue, + optionName, + `an integer from ${minValue} to ${maxValue}`, + minValue, + maxValue, + ); } diff --git a/src/cli/review.ts b/src/cli/review.ts index 0029746f..c7cfff19 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -5,7 +5,7 @@ import type { BuildReport } from "../indexer/types.js"; import type { GraphBuildOptions } from "../graphs.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; import type { ProjectFileDiscoveryOptions } from "../util.js"; -import { parseCacheModeOption } from "./options.js"; +import { parseCacheModeOption, parseOptionalNonNegativeIntegerOption } from "./options.js"; type CommandTimingReport = { totalMs?: number; @@ -159,16 +159,16 @@ export async function handleReviewCommand(context: ReviewCommandContext): Promis context.exit(2); } const threadsRaw = context.getOpt("--threads"); - const threads = threadsRaw !== undefined ? Number(threadsRaw) : undefined; + const threads = parseOptionalNonNegativeIntegerOption(threadsRaw, "--threads"); const cache = parseCacheModeOption(context.getOpt("--cache")); const cacheStrict = context.hasFlag("--cache-strict"); const cacheVerify = context.hasFlag("--cache-verify"); const incrementalStrict = context.hasFlag("--incremental-strict"); const includeSymbolDetails = context.hasFlag("--include-symbol-details"); const maxCallsitesRaw = context.getOpt("--max-callsites"); - const maxCallsites = maxCallsitesRaw !== undefined ? Number(maxCallsitesRaw) : undefined; + const maxCallsites = parseOptionalNonNegativeIntegerOption(maxCallsitesRaw, "--max-callsites"); const maxTestsRaw = context.getOpt("--max-tests"); - const maxTests = maxTestsRaw !== undefined ? Number(maxTestsRaw) : undefined; + const maxTests = parseOptionalNonNegativeIntegerOption(maxTestsRaw, "--max-tests"); const reviewOpts: Parameters[1] = {}; reviewOpts.discovery = context.discoveryOptions; if (reviewDepth) reviewOpts.reviewDepth = reviewDepth; diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index a1296d41..801d813a 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -544,6 +544,36 @@ describe("CLI command modules", () => { expect(stderrLines).toContain(" --text Force text chunking mode"); }); + test("rejects invalid chunk token bounds", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-chunk-invalid-bounds-")); + const filePath = path.join(tempDir, "sample.ts"); + await fsp.writeFile(filePath, "export const value = 1;\n", "utf8"); + const stderrLines: string[] = []; + + try { + await expect( + handleChunkCommand( + createChunkContext({ + positionals: ["sample.ts"], + cwd: () => tempDir, + getOpt: (name) => { + if (name === "--min-tokens") return "100"; + if (name === "--max-tokens") return "50"; + return undefined; + }, + writeStderrLine: (message) => stderrLines.push(message), + }), + ), + ).rejects.toThrow("chunk exit 1"); + + expect(stderrLines).toEqual([ + 'Chunking failed: Invalid --max-tokens value "50". Expected a value greater than or equal to --min-tokens.', + ]); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } + }); + test("forces text chunking with inferred data-file language ids", async () => { const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-chunk-text-")); const filePath = path.join(tempDir, "sample.json"); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index ea957a00..ecf63fe2 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -916,6 +916,12 @@ describe("CLI regressions", () => { ); }); + it("graph rejects invalid integer options", async () => { + await expect(runCliCommand(["graph", "--stdout", "--root", tsRoot, "--threads", "1.5"])).rejects.toThrow( + /Invalid --threads value "1.5"/i, + ); + }); + it("inspect rejects invalid --cache values", async () => { await expect(runCliCommand(["inspect", "--root", tsRoot, "--cache", "banana"])).rejects.toThrow( /Invalid --cache value "banana"/i, @@ -1167,6 +1173,12 @@ describe("CLI regressions", () => { expect(hits.every((h) => typeof h.line === "number" && typeof h.column === "number")).toBe(true); }); + it("grep rejects invalid --max-hits values", async () => { + await expect( + runCliCommand(["grep", "--root", tsRoot, "--pattern", "helperFunction", "--max-hits", "0"]), + ).rejects.toThrow(/Invalid --max-hits value "0"/i); + }); + it("grep honors .gitignore and additive scan globs", async () => { const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-grep-scan-")); const appFile = path.join(tmpDir, "src", "app.ts"); @@ -1637,6 +1649,20 @@ index 1111111..2222222 100644 expect(greet?.callsites).toBeUndefined(); }); + it("review CLI rejects invalid numeric limits", async () => { + const root = await mkTmpDir("dg-review-cli-invalid-number-"); + initGitRepo(root); + await fsp.writeFile(path.join(root, "main.ts"), "export function value() { return 1; }\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "initial"]); + + await fsp.writeFile(path.join(root, "main.ts"), "export function value() { return 2; }\n", "utf8"); + + await expect( + runCliCommand(["review", "--root", root, "--base", "HEAD", "--head", "WORKTREE", "--max-tests", "nope"]), + ).rejects.toThrow(/Invalid --max-tests value "nope"/i); + }); + it("review summary groups candidate tests by confidence without listing low-confidence fallbacks", async () => { const root = await mkTmpDir("dg-review-summary-candidates-"); const srcDir = path.join(root, "src"); diff --git a/tests/impact-cli.test.ts b/tests/impact-cli.test.ts index 1af9fa25..37e2a47a 100644 --- a/tests/impact-cli.test.ts +++ b/tests/impact-cli.test.ts @@ -102,6 +102,12 @@ describe("impact CLI output", () => { expect(Array.isArray(report.files)).toBe(true); }, slowCliTimeoutMs); + it("rejects invalid numeric analysis options", async () => { + await expect(runImpactCli(["impact", sampleRoot, "--provider", "raw", "--max-refs", "NaN"])).rejects.toThrow( + /Invalid --max-refs value "NaN"/i, + ); + }, slowCliTimeoutMs); + it("renders Mermaid output and honors graph/cache flags", async () => { const stdout = await runImpactCli([ "impact", From fc4935bb808438e6915e9b09544a52b74b32d299 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 16:51:39 -0400 Subject: [PATCH 04/25] Cover scoped cache reports --- REVIEW_ANALYSIS_NEXT.md | 2 +- tests/cli-regressions.test.ts | 97 +++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 2501ca06..556eefae 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -45,7 +45,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Add shared helpers in `src/cli/options.ts` for positive integers, non-negative integers, optional integers, and bounded integers. - Cover invalid and boundary values in `tests/cli-regressions.test.ts`, `tests/cli-command-modules.test.ts`, and command-specific tests. -- [ ] Add regression coverage for scoped cache behavior in `inspect` and `hotspots`. +- [x] Add regression coverage for scoped cache behavior in `inspect` and `hotspots`. - Finding: `buildScopedReportGraph` combines disk cache reuse with include-root restriction. The flow is subtle and easy to regress. - Add tests proving file counts, hotspots, cycles, and unresolved summaries are scoped when include roots are passed, both with cold builds and warm disk cache. diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index ecf63fe2..586f4b25 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -757,16 +757,24 @@ describe("CLI regressions", () => { "utf8", ); - await runCliCommand(["index", "--root", tmpDir]); - const result = await runCliCommandDetailed(["hotspots", "--root", tmpDir, srcDir, "--limit", "1", "--json"]); - - const hotspots = JSON.parse(result.stdout) as Array<{ + const coldResult = await runCliCommandDetailed([ + "hotspots", + "--root", + tmpDir, + srcDir, + "--limit", + "1", + "--json", + "--cache", + "off", + ]); + const coldHotspots = JSON.parse(coldResult.stdout) as Array<{ file: string; fanIn: number; fanOut: number; score: number; }>; - expect(hotspots).toEqual([ + expect(coldHotspots).toEqual([ { file: normalize(path.join(srcDir, "b.ts")), fanIn: 1, @@ -774,6 +782,18 @@ describe("CLI regressions", () => { score: 2, }, ]); + expect(coldResult.stderr).not.toContain("Index cache: manifest="); + + await runCliCommand(["index", "--root", tmpDir]); + const result = await runCliCommandDetailed(["hotspots", "--root", tmpDir, srcDir, "--limit", "1", "--json"]); + + const hotspots = JSON.parse(result.stdout) as Array<{ + file: string; + fanIn: number; + fanOut: number; + score: number; + }>; + expect(hotspots).toEqual(coldHotspots); expect(result.stderr).toContain("Index cache: manifest="); expect(result.stderr).toContain("lastCommit="); }); @@ -885,6 +905,73 @@ describe("CLI regressions", () => { ); }); + it("inspect keeps scoped summaries with cold builds and warm disk cache", async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-inspect-cache-scope-")); + const srcDir = path.join(tmpDir, "src"); + const testDir = path.join(tmpDir, "tests"); + await fsp.mkdir(srcDir, { recursive: true }); + await fsp.mkdir(testDir, { recursive: true }); + await fsp.writeFile( + path.join(srcDir, "a.ts"), + "import { b } from './b';\nimport { missing } from './missing';\nexport const a = b + missing;\n", + "utf8", + ); + await fsp.writeFile(path.join(srcDir, "b.ts"), "import { a } from './a';\nexport const b = a;\n", "utf8"); + await fsp.writeFile( + path.join(testDir, "spec.ts"), + "import { a } from '../src/a';\nexport const spec = a;\n", + "utf8", + ); + + const readReport = (stdout: string) => + JSON.parse(stdout) as { + files: { total: number; byLanguage: Record }; + hotspots: Array<{ file: string; fanIn: number; fanOut: number; score: number }>; + unresolved: { total: number; top: Array<{ name: string; importerCount: number }> }; + cycles: { total: number; top: Array<{ files: string[]; size: number }> }; + indexCache?: { manifestPath: string }; + }; + const expectScopedReport = (report: ReturnType) => { + expect(report.files.total).toBe(2); + expect(report.files.byLanguage.ts).toBe(2); + expect(report.hotspots).toEqual([ + { + file: normalize(path.join(srcDir, "a.ts")), + fanIn: 1, + fanOut: 2, + score: 4, + }, + { + file: normalize(path.join(srcDir, "b.ts")), + fanIn: 1, + fanOut: 1, + score: 3, + }, + ]); + expect(report.unresolved).toEqual({ total: 1, top: [{ name: "./missing", importerCount: 1 }] }); + expect(report.cycles.total).toBe(1); + expect(report.cycles.top[0]?.files.sort()).toEqual( + [path.join(srcDir, "a.ts"), path.join(srcDir, "b.ts")].map(normalize).sort(), + ); + expect(report.cycles.top[0]?.size).toBe(2); + }; + + const coldResult = await runCliCommandDetailed(["inspect", "--root", tmpDir, srcDir, "--limit", "5"]); + const coldReport = readReport(coldResult.stdout); + expectScopedReport(coldReport); + expect(coldReport.indexCache).toBeUndefined(); + expect(coldResult.stderr).not.toContain("Index cache: manifest="); + + await runCliCommand(["index", "--root", tmpDir]); + const warmResult = await runCliCommandDetailed(["inspect", "--root", tmpDir, srcDir, "--limit", "5"]); + const warmReport = readReport(warmResult.stdout); + expectScopedReport(warmReport); + expect(warmReport.indexCache?.manifestPath).toBe( + normalize(path.join(tmpDir, ".codegraph-cache", "index-v1", "manifest.json")), + ); + expect(warmResult.stderr).toContain("Index cache: manifest="); + }); + it("unresolved filters declared dependencies for scoped roots", async () => { const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-cli-unresolved-scoped-")); try { From 0da9d2065e38a42a4a87e03cc4d2391b6e49ffad Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 17:05:23 -0400 Subject: [PATCH 05/25] Extract focused CLI command modules --- src/cli.ts | 624 +++++++----------------------------------- src/cli/grep.ts | 44 +++ src/cli/index.ts | 118 ++++++++ src/cli/inspect.ts | 332 ++++++++++++++++++++++ src/cli/navigation.ts | 157 +++++++++++ 5 files changed, 747 insertions(+), 528 deletions(-) create mode 100644 src/cli/grep.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/inspect.ts create mode 100644 src/cli/navigation.ts diff --git a/src/cli.ts b/src/cli.ts index 6c3c7b4d..cd048e7d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,58 +6,42 @@ import fsp from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { fileURLToPath } from "node:url"; import picomatch from "picomatch"; -import { - buildProjectIndex, - buildProjectIndexFromFiles, - buildProjectIndexIncremental, - goToDefinition, - findReferences, -} from "./indexer.js"; +import { buildProjectIndex, buildProjectIndexFromFiles, buildProjectIndexIncremental } from "./indexer.js"; import type { BuildOptions, BuildReport } from "./indexer/types.js"; import type { ReviewBuildReport } from "./review.js"; import { collectGraph, graphToMermaid, graphToDOT, - astGrep, - textGrep, buildSymbolGraph, buildSymbolGraphDetailed, graphToMermaidSymbols, graphToDOTSymbols, graphToMermaidSymbolsWithFiles, graphToDOTSymbolsWithFiles, - findDetailedCycles, - sortDetailedCycles, - getUnresolvedImports, - getHotspots, type GraphBuildOptions, type SymbolGraph, } from "./graphs.js"; import { writeGraphSqlite, updateGraphSqlite } from "./sqlite.js"; -import { - isNativeTreeSitterAvailable, - getNativeTreeSitterLoadError, - getNativeTreeSitterSupportedLanguageIds, - type NativeRuntimeMode, -} from "./native/treeSitterNative.js"; -import { supportForFile } from "./languages.js"; +import { type NativeRuntimeMode } from "./native/treeSitterNative.js"; import { handleChunkCommand } from "./cli/chunk.js"; import { handleArtifactCommand } from "./cli/artifact.js"; import { buildDoctorReport } from "./cli/doctor.js"; import { handleExplainCommand } from "./cli/explain.js"; import { handleGraphDeltaCommand } from "./cli/graphDelta.js"; import { handleGraphQueryCommand } from "./cli/graphQueries.js"; +import { handleGrepCommand } from "./cli/grep.js"; import { CLI_HELP_TEXT, helpTextForCommand, isKnownCliCommand } from "./cli/help.js"; import { handleImpactCommand } from "./cli/impact.js"; +import { handleIndexCommand } from "./cli/index.js"; +import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleMcpServeCommand } from "./cli/mcp.js"; +import { handleDumpmodCommand, handleGotoCommand, handleRefsCommand } from "./cli/navigation.js"; import { isCliValueOption, parseCacheModeOption, parseNonNegativeIntegerOption, parseOptionalNonNegativeIntegerOption, - parseOptionalPositiveIntegerOption, - parsePositiveIntegerOption, } from "./cli/options.js"; import { getCodegraphPackageIdentity, getCodegraphVersion } from "./cli/packageInfo.js"; import { handleReviewCommand } from "./cli/review.js"; @@ -68,7 +52,6 @@ import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from import { buildSqlArtifactGraphFromFiles } from "./sql/index.js"; import type { Graph } from "./types.js"; import { - assertFilePathWithinRoot, listChangedFiles, listProjectFiles, normalizePath, @@ -302,278 +285,6 @@ type ParsedCliArgs = { options: Map; }; -type IndexCacheMetadata = { - manifestPath: string; - updatedAt?: number; - lastCommit?: string; -}; - -type InspectReport = { - root: string; - includeRoots: string[]; - indexCache?: IndexCacheMetadata; - backend: { - native: { - available: boolean; - loadError?: string; - supportedLanguageIds: string[]; - }; - }; - files: { - total: number; - byLanguage: Record; - }; - hotspots: Array<{ - file: string; - fanIn: number; - fanOut: number; - score: number; - }>; - unresolved: { - total: number; - top: Array<{ name: string; importerCount: number }>; - }; - cycles: { - total: number; - top: Array<{ - files: string[]; - priorityScore: number; - size: number; - }>; - }; - recommendedCommands: string[]; -}; - -function normalizePathForDisplay(filePath: string): string { - return filePath.replace(/\\/g, "/"); -} - -type CliProjectFileInput = - | { status: "ok"; file: string } - | { status: "error"; reason: "outside_project_root"; error: string }; - -function resolveCliProjectFile(projectRoot: string, fileArg: string, label: string): CliProjectFileInput { - try { - return { - status: "ok", - file: assertFilePathWithinRoot(projectRoot, fileArg, label), - }; - } catch (error) { - return { - status: "error", - reason: "outside_project_root", - error: error instanceof Error ? error.message : String(error), - }; - } -} - -function writeCliProjectFileError( - result: Extract, - output: "json" | "text" = "json", -): void { - if (output === "json") { - writeJSONLine(result); - return; - } - writeStdoutLine(`error: ${result.reason}: ${result.error}`); -} - -function defaultCacheIndexPath(projectRoot: string): string { - return path.join(projectRoot, ".codegraph-cache", "index-v1"); -} - -function defaultCacheManifestPath(projectRoot: string): string { - return path.join(defaultCacheIndexPath(projectRoot), "manifest.json"); -} - -function readIndexCacheMetadata(projectRoot: string): IndexCacheMetadata | null { - const manifestPath = defaultCacheManifestPath(projectRoot); - try { - const raw = fs.readFileSync(manifestPath, "utf8"); - const parsed = JSON.parse(raw) as { - updatedAt?: number; - lastCommit?: string; - }; - return { - manifestPath: normalizePathForDisplay(manifestPath), - ...(typeof parsed.updatedAt === "number" ? { updatedAt: parsed.updatedAt } : {}), - ...(typeof parsed.lastCommit === "string" && parsed.lastCommit ? { lastCommit: parsed.lastCommit } : {}), - }; - } catch { - return null; - } -} - -function formatIndexCacheMetadata(metadata: IndexCacheMetadata): string { - const updatedAt = metadata.updatedAt !== undefined ? new Date(metadata.updatedAt).toISOString() : "unknown"; - const lastCommit = metadata.lastCommit ?? "unknown"; - return `Index cache: manifest=${metadata.manifestPath} updatedAt=${updatedAt} lastCommit=${lastCommit}`; -} - -async function buildScopedReportGraph( - projectRoot: string, - includeRoots: string[], - files: string[], - opts: { - cache?: "off" | "memory" | "disk"; - discovery?: ProjectFileDiscoveryOptions; - graphOptions?: GraphBuildOptions; - nativeMode?: NativeRuntimeMode; - workerOpts?: { useNativeWorkers: true } | Record; - progressHandler?: ((update: { current: number; total: number }) => void) | undefined; - report?: BuildReport; - }, -): Promise<{ graph: Graph; indexCache?: IndexCacheMetadata }> { - const useDiskCache = opts.cache === "disk" || opts.cache === undefined; - const indexCache = useDiskCache ? readIndexCacheMetadata(projectRoot) : null; - if (indexCache) { - writeStderrLine(formatIndexCacheMetadata(indexCache)); - const index = await buildProjectIndexIncremental(projectRoot, { - files, - cache: "disk", - ...(opts.discovery ? { discovery: opts.discovery } : {}), - ...(opts.progressHandler ? { onProgress: opts.progressHandler } : {}), - ...(opts.nativeMode && opts.nativeMode !== "auto" ? { native: opts.nativeMode } : {}), - ...(opts.workerOpts ?? {}), - ...(opts.graphOptions ? { graph: opts.graphOptions } : {}), - ...(opts.report ? { report: opts.report } : {}), - }); - return { - graph: restrictGraphToIncludeRoots(index.graph, includeRoots), - indexCache, - }; - } - - const sourceGraph = await collectGraph(projectRoot, files, { - ...(opts.graphOptions ?? {}), - ...(opts.report ? { report: opts.report } : {}), - }); - return { - graph: restrictGraphToIncludeRoots(sourceGraph, includeRoots), - }; -} - -function countFilesByLanguage(files: Iterable): Record { - const byLanguage: Record = {}; - for (const file of files) { - const languageId = supportForFile(file)?.id ?? "other"; - byLanguage[languageId] = (byLanguage[languageId] ?? 0) + 1; - } - return byLanguage; -} - -function buildRecommendedInspectCommands( - projectRoot: string, - includeRoots: string[], - hasCycles: boolean, - hasUnresolvedImports: boolean, -): string[] { - const rootFlag = `--root "${normalizePathForDisplay(projectRoot)}"`; - const targetSuffix = includeRoots.length - ? ` ${includeRoots.map((root) => `"${normalizePathForDisplay(root)}"`).join(" ")}` - : ""; - const commands = [ - `codegraph hotspots ${rootFlag}${targetSuffix} --limit 20 --json`, - `codegraph graph ${rootFlag}${targetSuffix} --json --symbols-detailed --compact-json`, - ]; - if (hasUnresolvedImports) { - commands.push(`codegraph unresolved ${rootFlag}${targetSuffix} --json`); - } - if (hasCycles) { - commands.push(`codegraph cycles ${rootFlag}${targetSuffix} --sort priority --json`); - } - commands.push(`codegraph doctor "${normalizePathForDisplay(defaultCacheIndexPath(projectRoot))}"`); - return commands; -} - -function restrictGraphToIncludeRoots(graph: Graph, includeRoots: string[]): Graph { - if (!includeRoots.length) { - return graph; - } - const normalizedRoots = includeRoots.map(normalizePathForDisplay); - const nodes = new Set(); - for (const file of graph.nodes) { - const normalizedFile = normalizePathForDisplay(file); - if (normalizedRoots.some((root) => normalizedFile === root || normalizedFile.startsWith(`${root}/`))) { - nodes.add(normalizedFile); - } - } - const edges = graph.edges.filter((edge) => { - if (!nodes.has(normalizePathForDisplay(edge.from))) { - return false; - } - return edge.to.type === "external" || nodes.has(normalizePathForDisplay(edge.to.path)); - }); - return { - nodes, - edges, - }; -} - -async function buildInspectReport( - projectRoot: string, - includeRoots: string[], - files: string[], - discovery: ProjectFileDiscoveryOptions, - graphOptions: GraphBuildOptions | undefined, - cache: "off" | "memory" | "disk" | undefined, - nativeMode: NativeRuntimeMode, - workerOpts: { useNativeWorkers: true } | Record, - progressHandler: ((update: { current: number; total: number }) => void) | undefined, - limit: number, -): Promise { - const { graph, indexCache } = await buildScopedReportGraph(projectRoot, includeRoots, files, { - ...(cache ? { cache } : {}), - discovery, - ...(graphOptions ? { graphOptions } : {}), - nativeMode, - workerOpts, - ...(progressHandler ? { progressHandler } : {}), - }); - const hotspots = getHotspots(graph, { limit }); - const unresolved = getUnresolvedImports(graph, { projectRoot }); - const cycles = sortDetailedCycles(findDetailedCycles(graph), "priority"); - const loadError = getNativeTreeSitterLoadError(nativeMode); - return { - root: normalizePathForDisplay(projectRoot), - includeRoots: includeRoots.map(normalizePathForDisplay), - ...(indexCache ? { indexCache } : {}), - backend: { - native: { - available: isNativeTreeSitterAvailable(nativeMode), - ...(loadError ? { loadError: String(loadError) } : {}), - supportedLanguageIds: getNativeTreeSitterSupportedLanguageIds(nativeMode), - }, - }, - files: { - total: files.length, - byLanguage: countFilesByLanguage(files), - }, - hotspots, - unresolved: { - total: unresolved.length, - top: unresolved.slice(0, limit).map((entry) => ({ - name: entry.name, - importerCount: entry.importers.length, - })), - }, - cycles: { - total: cycles.length, - top: cycles.slice(0, limit).map((cycle) => ({ - files: cycle.files.map(normalizePathForDisplay), - priorityScore: cycle.priorityScore, - size: cycle.files.length, - })), - }, - recommendedCommands: buildRecommendedInspectCommands( - projectRoot, - includeRoots, - !!cycles.length, - !!unresolved.length, - ), - }; -} - function parseCliArgs(command: string, tokens: string[]): ParsedCliArgs { const positionals: string[] = []; const flags = new Set(); @@ -1407,228 +1118,95 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { } if (cmd === "index") { - const verbose = hasFlag("--verbose"); - const commandReport: CommandReport | undefined = reportEnabled ? { command: "index", timings: {} } : undefined; - const commandStart = performance.now(); - const resolveStart = performance.now(); - const files = await resolveFiles(); - if (commandReport) { - commandReport.timings.resolveFilesMs = Math.round(performance.now() - resolveStart); - } - const threads = parseNonNegativeIntegerOption(getOpt("--threads"), "--threads", 0); - const cache = parseCacheModeOption(getOpt("--cache")); - const cacheStrict = hasFlag("--cache-strict"); - const full = hasFlag("--json") || hasFlag("--full"); - const cacheVerify = hasFlag("--cache-verify"); - const shouldWriteManifest = !includeRootsAbs.length && !gitBase && !changedSince; - const graphOptions = hasGraphOverrides ? buildGraphOptions() : undefined; - const indexReport: BuildReport | undefined = reportEnabled || verbose ? { timings: {} } : undefined; - if (commandReport && indexReport) { - commandReport.index = indexReport; - } - const baseIndexOptions: BuildOptions = { - onProgress: progressHandler, - threads, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, - ...(cache !== undefined ? { cache } : {}), - cacheStrict, - cacheVerify, - ...(graphOptions ? { graph: graphOptions } : {}), - ...(indexReport ? { report: indexReport } : {}), - }; - const index = shouldWriteManifest - ? await buildProjectIndex(projectRootFs, baseIndexOptions) - : await buildProjectIndexFromFiles(projectRootFs, files, baseIndexOptions); - maybeWriteNativeBackendStatus(indexReport, showProgress); - if (full) { - const modules = [...index.byFile.values()].map((m) => ({ - file: m.file, - locals: m.locals.map((l) => ({ - name: l.localName, - kind: l.kind, - start: l.range.start, - })), - exports: m.exports, - imports: m.imports, - })); - writeJSONLine({ - files: modules.length, - edges: index.graph.edges.length, - modules, - }); - } else { - writeJSONLine({ - files: [...index.byFile.keys()].length, - edges: index.graph.edges.length, - }); - } - if (verbose && indexReport) { - const cache = indexReport.cache; - const fileStats = indexReport.files; - if (cache) { - writeStderrLine(`Cache (${cache.mode}): ${cache.hits} hits, ${cache.misses} misses`); - } - if (fileStats) { - writeStderrLine( - `Files: ${fileStats.parsed ?? 0} parsed, ${fileStats.cached ?? 0} cached, ${fileStats.total} total`, - ); - } - } - if (commandReport) { - commandReport.timings.commandMs = Math.round(performance.now() - commandStart); - commandReport.timings.totalMs = commandReport.timings.commandMs; - await writeCommandReport(commandReport, reportFile); - } + await handleIndexCommand({ + projectRootFs, + includeRootsAbs, + gitBase, + changedSince, + discoveryOptions, + nativeMode, + workerOpts, + progressHandler, + graphOptions: hasGraphOverrides ? buildGraphOptions() : undefined, + reportEnabled, + reportFile, + getOpt, + hasFlag, + resolveFiles, + writeJSONLine, + writeStderrLine, + writeCommandReport, + maybeWriteNativeBackendStatus, + showProgress, + }); return; } if (cmd === "dumpmod") { - const [fileArg] = parsed.positionals; - if (!fileArg) { - writeStderrLine("Usage: dumpmod "); - exitCli(2); - } - const resolvedFile = resolveCliProjectFile(projectRootFs, fileArg, "File"); - if (resolvedFile.status === "error") { - writeCliProjectFileError(resolvedFile); - return; - } - const file = resolvedFile.file; - const index = await buildProjectIndex(projectRootFs, { - onProgress: progressHandler, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, - }); - const mod = index.byFile.get(file); - if (!mod) { - writeJSONLine({ - status: "not_found", - reason: "Module not indexed", - file, - }); - return; - } - writeJSONLine({ - file, - locals: mod.locals.map((l) => ({ - name: l.localName, - kind: l.kind, - start: l.range.start, - })), - exports: mod.exports.map((e) => - e.type === "local" - ? { - type: e.type, - exportedAs: e.exportedAs, - def: { - name: e.target.localName, - kind: e.target.kind, - start: e.target.range.start, - }, - } - : e, - ), - imports: mod.imports, + await handleDumpmodCommand({ + projectRootFs, + discoveryOptions, + positionals: parsed.positionals, + getOpt, + hasFlag, + nativeMode, + workerOpts, + progressHandler, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, }); return; } if (cmd === "goto") { - const [fileArg, lineArg, colArg] = parsed.positionals; - if (!fileArg || !lineArg || !colArg) { - writeStderrLine("Usage: goto "); - exitCli(2); - } - const resolvedFile = resolveCliProjectFile(projectRootFs, fileArg, "File"); - if (resolvedFile.status === "error") { - writeCliProjectFileError(resolvedFile); - return; - } - const file = resolvedFile.file; - const line = parsePositiveIntegerOption(lineArg, "line", 1); - const column = parsePositiveIntegerOption(colArg, "column", 1); - const index = await buildProjectIndex(projectRootFs, { - onProgress: progressHandler, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, + await handleGotoCommand({ + projectRootFs, + discoveryOptions, + positionals: parsed.positionals, + getOpt, + hasFlag, + nativeMode, + workerOpts, + progressHandler, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, }); - const res = await goToDefinition(index, { file, line, column }); - writeJSONLine(res); return; } if (cmd === "refs") { - const fileArg = getOpt("--file"); - const lineArg = getOpt("--line"); - const colArg = getOpt("--col") ?? getOpt("--column"); - if (!fileArg || !lineArg || !colArg) { - writeStderrLine("Usage: refs --file --line --col "); - exitCli(2); - } - const line = parsePositiveIntegerOption(lineArg, "--line", 1); - const column = parsePositiveIntegerOption(colArg, "--col", 1); - const pretty = hasFlag("--pretty"); - const resolvedFile = resolveCliProjectFile(projectRootFs, fileArg, "File"); - if (resolvedFile.status === "error") { - writeCliProjectFileError(resolvedFile, pretty ? "text" : "json"); - return; - } - const file = resolvedFile.file; - const index = await buildProjectIndex(projectRootFs, { - onProgress: progressHandler, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, + await handleRefsCommand({ + projectRootFs, + discoveryOptions, + positionals: parsed.positionals, + getOpt, + hasFlag, + nativeMode, + workerOpts, + progressHandler, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, }); - const res = await findReferences(index, { file, line, column }); - if (!pretty) { - writeJSONLine(res); - return; - } - if (res.status === "ok") { - for (const r of res.references) { - const rel = path.relative(projectRootFs, r.file); - const { line, column } = r.range.start; - writeStdoutLine(`${rel}:${line}:${column}`); - } - } else { - writeStdoutLine(`not_found: ${res.reason}`); - } return; } if (cmd === "grep") { - const querySource = getOpt("--query"); - const patternSource = getOpt("--pattern") ?? getOpt("--regex"); - const globs = parsed.options.get("--glob") ?? []; - const patterns = globs.length ? globs : undefined; - - if ((querySource ? 1 : 0) + (patternSource ? 1 : 0) !== 1) { - writeStderrLine( - "Usage: grep [--root ] (--query '' | --pattern '') [--glob ''] [--ignore-case] [--max-hits N]", - ); - exitCli(2); - } - - if (querySource) { - const hits = await astGrep(projectRootFs, querySource, patterns, discoveryOptions); - writeJSONLine(hits); - return; - } - - const ignoreCase = hasFlag("--ignore-case") || hasFlag("-i"); - const maxHitsRaw = getOpt("--max-hits"); - const maxHits = parseOptionalPositiveIntegerOption(maxHitsRaw, "--max-hits"); - const hits = await textGrep(projectRootFs, patternSource!, patterns, { - ignoreCase, - ...(maxHits !== undefined ? { maxHits } : {}), - ...discoveryOptions, + await handleGrepCommand({ + projectRootFs, + discoveryOptions, + parsedOptions: parsed.options, + getOpt, + hasFlag, + writeJSONLine, + writeStderrLine, + exit: exitCli, }); - writeJSONLine(hits); return; } @@ -1737,50 +1315,40 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { } if (cmd === "inspect") { - const cache = parseCacheModeOption(getOpt("--cache")); - const limit = parsePositiveIntegerOption(getOpt("--limit"), "--limit", 20); - const files = await resolveFilesFromRoots(); - const report = await buildInspectReport( + await handleInspectCommand({ projectRootFs, includeRootsAbs, - files, discoveryOptions, - hasGraphOverrides || nativeMode !== "auto" ? buildGraphOptions() : undefined, - cache, + graphOptions: hasGraphOverrides || nativeMode !== "auto" ? buildGraphOptions() : undefined, nativeMode, workerOpts, progressHandler, - limit, - ); - writeJSONLine(report); + getOpt, + hasFlag, + resolveFilesFromRoots, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + }); return; } if (cmd === "hotspots") { - const json = hasFlag("--json"); - const cache = parseCacheModeOption(getOpt("--cache")); - const limit = parsePositiveIntegerOption(getOpt("--limit"), "--limit", 20); - const files = await resolveFilesFromRoots(); - const { graph } = await buildScopedReportGraph(projectRootFs, includeRootsAbs, files, { - ...(cache ? { cache } : {}), - discovery: discoveryOptions, - ...(hasGraphOverrides || nativeMode !== "auto" ? { graphOptions: buildGraphOptions() } : {}), + await handleHotspotsCommand({ + projectRootFs, + includeRootsAbs, + discoveryOptions, + graphOptions: hasGraphOverrides || nativeMode !== "auto" ? buildGraphOptions() : undefined, nativeMode, workerOpts, - ...(progressHandler ? { progressHandler } : {}), + progressHandler, + getOpt, + hasFlag, + resolveFilesFromRoots, + writeJSONLine, + writeStdoutLine, + writeStderrLine, }); - const hotspots = getHotspots(graph, { limit }); - - if (json) { - writeJSONLine(hotspots); - } else { - writeStdoutLine("Top hotspots (files with high fan-in/out):"); - for (const item of hotspots) { - writeStdoutLine( - `- ${path.relative(projectRootFs, item.file)} (fan-in: ${item.fanIn}, fan-out: ${item.fanOut}, score: ${item.score.toFixed(1)})`, - ); - } - } return; } diff --git a/src/cli/grep.ts b/src/cli/grep.ts new file mode 100644 index 00000000..514805be --- /dev/null +++ b/src/cli/grep.ts @@ -0,0 +1,44 @@ +import { astGrep, textGrep } from "../graphs.js"; +import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { parseOptionalPositiveIntegerOption } from "./options.js"; + +export type GrepCommandContext = { + projectRootFs: string; + discoveryOptions: ProjectFileDiscoveryOptions; + parsedOptions: ReadonlyMap; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + writeJSONLine: (value: unknown) => void; + writeStderrLine: (message: string) => void; + exit: (code: number) => never; +}; + +export async function handleGrepCommand(context: GrepCommandContext): Promise { + const querySource = context.getOpt("--query"); + const patternSource = context.getOpt("--pattern") ?? context.getOpt("--regex"); + const globs = context.parsedOptions.get("--glob") ?? []; + const patterns = globs.length ? [...globs] : undefined; + + if ((querySource ? 1 : 0) + (patternSource ? 1 : 0) !== 1) { + context.writeStderrLine( + "Usage: grep [--root ] (--query '' | --pattern '') [--glob ''] [--ignore-case] [--max-hits N]", + ); + context.exit(2); + } + + if (querySource) { + const hits = await astGrep(context.projectRootFs, querySource, patterns, context.discoveryOptions); + context.writeJSONLine(hits); + return; + } + + const ignoreCase = context.hasFlag("--ignore-case") || context.hasFlag("-i"); + const maxHitsRaw = context.getOpt("--max-hits"); + const maxHits = parseOptionalPositiveIntegerOption(maxHitsRaw, "--max-hits"); + const hits = await textGrep(context.projectRootFs, patternSource!, patterns, { + ignoreCase, + ...(maxHits !== undefined ? { maxHits } : {}), + ...context.discoveryOptions, + }); + context.writeJSONLine(hits); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 00000000..c8f5097d --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,118 @@ +import { performance } from "node:perf_hooks"; +import { buildProjectIndex, buildProjectIndexFromFiles, type BuildOptions, type BuildReport } from "../indexer.js"; +import type { GraphBuildOptions } from "../graphs.js"; +import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; +import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { parseCacheModeOption, parseNonNegativeIntegerOption } from "./options.js"; + +type CommandTimingReport = { + totalMs?: number; + resolveFilesMs?: number; + commandMs?: number; +}; + +type IndexCommandReport = { + command: string; + timings: CommandTimingReport; + index?: BuildReport; +}; + +export type IndexCommandContext = { + projectRootFs: string; + includeRootsAbs: string[]; + gitBase: string | undefined; + changedSince: string | undefined; + discoveryOptions: ProjectFileDiscoveryOptions; + nativeMode: NativeRuntimeMode; + workerOpts: { useNativeWorkers: true } | Record; + progressHandler: ((update: { current: number; total: number }) => void) | undefined; + graphOptions: GraphBuildOptions | undefined; + reportEnabled: boolean; + reportFile: string | undefined; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + resolveFiles: () => Promise; + writeJSONLine: (value: unknown) => void; + writeStderrLine: (message: string) => void; + writeCommandReport: (report: IndexCommandReport, reportFile: string | undefined) => Promise; + maybeWriteNativeBackendStatus: (report: BuildReport | undefined, showProgress: boolean) => void; + showProgress: boolean; +}; + +export async function handleIndexCommand(context: IndexCommandContext): Promise { + const verbose = context.hasFlag("--verbose"); + const commandReport: IndexCommandReport | undefined = context.reportEnabled + ? { command: "index", timings: {} } + : undefined; + const commandStart = performance.now(); + const resolveStart = performance.now(); + const files = await context.resolveFiles(); + if (commandReport) { + commandReport.timings.resolveFilesMs = Math.round(performance.now() - resolveStart); + } + const threads = parseNonNegativeIntegerOption(context.getOpt("--threads"), "--threads", 0); + const cache = parseCacheModeOption(context.getOpt("--cache")); + const cacheStrict = context.hasFlag("--cache-strict"); + const full = context.hasFlag("--json") || context.hasFlag("--full"); + const cacheVerify = context.hasFlag("--cache-verify"); + const shouldWriteManifest = !context.includeRootsAbs.length && !context.gitBase && !context.changedSince; + const indexReport: BuildReport | undefined = context.reportEnabled || verbose ? { timings: {} } : undefined; + if (commandReport && indexReport) { + commandReport.index = indexReport; + } + const baseIndexOptions: BuildOptions = { + onProgress: context.progressHandler, + threads, + discovery: context.discoveryOptions, + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...context.workerOpts, + ...(cache !== undefined ? { cache } : {}), + cacheStrict, + cacheVerify, + ...(context.graphOptions ? { graph: context.graphOptions } : {}), + ...(indexReport ? { report: indexReport } : {}), + }; + const index = shouldWriteManifest + ? await buildProjectIndex(context.projectRootFs, baseIndexOptions) + : await buildProjectIndexFromFiles(context.projectRootFs, files, baseIndexOptions); + context.maybeWriteNativeBackendStatus(indexReport, context.showProgress); + if (full) { + const modules = [...index.byFile.values()].map((m) => ({ + file: m.file, + locals: m.locals.map((l) => ({ + name: l.localName, + kind: l.kind, + start: l.range.start, + })), + exports: m.exports, + imports: m.imports, + })); + context.writeJSONLine({ + files: modules.length, + edges: index.graph.edges.length, + modules, + }); + } else { + context.writeJSONLine({ + files: [...index.byFile.keys()].length, + edges: index.graph.edges.length, + }); + } + if (verbose && indexReport) { + const cache = indexReport.cache; + const fileStats = indexReport.files; + if (cache) { + context.writeStderrLine(`Cache (${cache.mode}): ${cache.hits} hits, ${cache.misses} misses`); + } + if (fileStats) { + context.writeStderrLine( + `Files: ${fileStats.parsed ?? 0} parsed, ${fileStats.cached ?? 0} cached, ${fileStats.total} total`, + ); + } + } + if (commandReport) { + commandReport.timings.commandMs = Math.round(performance.now() - commandStart); + commandReport.timings.totalMs = commandReport.timings.commandMs; + await context.writeCommandReport(commandReport, context.reportFile); + } +} diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts new file mode 100644 index 00000000..e46d0800 --- /dev/null +++ b/src/cli/inspect.ts @@ -0,0 +1,332 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + collectGraph, + findDetailedCycles, + getHotspots, + getUnresolvedImports, + sortDetailedCycles, + type GraphBuildOptions, +} from "../graphs.js"; +import { buildProjectIndexIncremental, type BuildReport } from "../indexer.js"; +import { + getNativeTreeSitterLoadError, + getNativeTreeSitterSupportedLanguageIds, + isNativeTreeSitterAvailable, + type NativeRuntimeMode, +} from "../native/treeSitterNative.js"; +import type { Graph } from "../types.js"; +import { supportForFile } from "../languages.js"; +import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { parseCacheModeOption, parsePositiveIntegerOption } from "./options.js"; + +type CacheMode = "off" | "memory" | "disk"; + +type IndexCacheMetadata = { + manifestPath: string; + updatedAt?: number; + lastCommit?: string; +}; + +type InspectReport = { + root: string; + includeRoots: string[]; + indexCache?: IndexCacheMetadata; + backend: { + native: { + available: boolean; + loadError?: string; + supportedLanguageIds: string[]; + }; + }; + files: { + total: number; + byLanguage: Record; + }; + hotspots: Array<{ + file: string; + fanIn: number; + fanOut: number; + score: number; + }>; + unresolved: { + total: number; + top: Array<{ name: string; importerCount: number }>; + }; + cycles: { + total: number; + top: Array<{ + files: string[]; + priorityScore: number; + size: number; + }>; + }; + recommendedCommands: string[]; +}; + +export type InspectCommandContext = { + projectRootFs: string; + includeRootsAbs: string[]; + discoveryOptions: ProjectFileDiscoveryOptions; + graphOptions: GraphBuildOptions | undefined; + nativeMode: NativeRuntimeMode; + workerOpts: { useNativeWorkers: true } | Record; + progressHandler: ((update: { current: number; total: number }) => void) | undefined; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + resolveFilesFromRoots: () => Promise; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; + writeStderrLine: (message: string) => void; +}; + +function normalizePathForDisplay(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +function defaultCacheIndexPath(projectRoot: string): string { + return path.join(projectRoot, ".codegraph-cache", "index-v1"); +} + +function defaultCacheManifestPath(projectRoot: string): string { + return path.join(defaultCacheIndexPath(projectRoot), "manifest.json"); +} + +function readIndexCacheMetadata(projectRoot: string): IndexCacheMetadata | null { + const manifestPath = defaultCacheManifestPath(projectRoot); + try { + const raw = fs.readFileSync(manifestPath, "utf8"); + const parsed = JSON.parse(raw) as { + updatedAt?: number; + lastCommit?: string; + }; + return { + manifestPath: normalizePathForDisplay(manifestPath), + ...(typeof parsed.updatedAt === "number" ? { updatedAt: parsed.updatedAt } : {}), + ...(typeof parsed.lastCommit === "string" && parsed.lastCommit ? { lastCommit: parsed.lastCommit } : {}), + }; + } catch { + return null; + } +} + +function formatIndexCacheMetadata(metadata: IndexCacheMetadata): string { + const updatedAt = metadata.updatedAt !== undefined ? new Date(metadata.updatedAt).toISOString() : "unknown"; + const lastCommit = metadata.lastCommit ?? "unknown"; + return `Index cache: manifest=${metadata.manifestPath} updatedAt=${updatedAt} lastCommit=${lastCommit}`; +} + +function restrictGraphToIncludeRoots(graph: Graph, includeRoots: string[]): Graph { + if (!includeRoots.length) { + return graph; + } + const normalizedRoots = includeRoots.map(normalizePathForDisplay); + const nodes = new Set(); + for (const file of graph.nodes) { + const normalizedFile = normalizePathForDisplay(file); + if (normalizedRoots.some((root) => normalizedFile === root || normalizedFile.startsWith(`${root}/`))) { + nodes.add(normalizedFile); + } + } + const edges = graph.edges.filter((edge) => { + if (!nodes.has(normalizePathForDisplay(edge.from))) { + return false; + } + return edge.to.type === "external" || nodes.has(normalizePathForDisplay(edge.to.path)); + }); + return { + nodes, + edges, + }; +} + +async function buildScopedReportGraph( + projectRoot: string, + includeRoots: string[], + files: string[], + opts: { + cache?: CacheMode; + discovery?: ProjectFileDiscoveryOptions; + graphOptions?: GraphBuildOptions; + nativeMode?: NativeRuntimeMode; + workerOpts?: { useNativeWorkers: true } | Record; + progressHandler?: ((update: { current: number; total: number }) => void) | undefined; + report?: BuildReport; + writeStderrLine: (message: string) => void; + }, +): Promise<{ graph: Graph; indexCache?: IndexCacheMetadata }> { + const useDiskCache = opts.cache === "disk" || opts.cache === undefined; + const indexCache = useDiskCache ? readIndexCacheMetadata(projectRoot) : null; + if (indexCache) { + opts.writeStderrLine(formatIndexCacheMetadata(indexCache)); + const index = await buildProjectIndexIncremental(projectRoot, { + files, + cache: "disk", + ...(opts.discovery ? { discovery: opts.discovery } : {}), + ...(opts.progressHandler ? { onProgress: opts.progressHandler } : {}), + ...(opts.nativeMode && opts.nativeMode !== "auto" ? { native: opts.nativeMode } : {}), + ...(opts.workerOpts ?? {}), + ...(opts.graphOptions ? { graph: opts.graphOptions } : {}), + ...(opts.report ? { report: opts.report } : {}), + }); + return { + graph: restrictGraphToIncludeRoots(index.graph, includeRoots), + indexCache, + }; + } + + const sourceGraph = await collectGraph(projectRoot, files, { + ...(opts.graphOptions ?? {}), + ...(opts.report ? { report: opts.report } : {}), + }); + return { + graph: restrictGraphToIncludeRoots(sourceGraph, includeRoots), + }; +} + +function countFilesByLanguage(files: Iterable): Record { + const byLanguage: Record = {}; + for (const file of files) { + const languageId = supportForFile(file)?.id ?? "other"; + byLanguage[languageId] = (byLanguage[languageId] ?? 0) + 1; + } + return byLanguage; +} + +function buildRecommendedInspectCommands( + projectRoot: string, + includeRoots: string[], + hasCycles: boolean, + hasUnresolvedImports: boolean, +): string[] { + const rootFlag = `--root "${normalizePathForDisplay(projectRoot)}"`; + const targetSuffix = includeRoots.length + ? ` ${includeRoots.map((root) => `"${normalizePathForDisplay(root)}"`).join(" ")}` + : ""; + const commands = [ + `codegraph hotspots ${rootFlag}${targetSuffix} --limit 20 --json`, + `codegraph graph ${rootFlag}${targetSuffix} --json --symbols-detailed --compact-json`, + ]; + if (hasUnresolvedImports) { + commands.push(`codegraph unresolved ${rootFlag}${targetSuffix} --json`); + } + if (hasCycles) { + commands.push(`codegraph cycles ${rootFlag}${targetSuffix} --sort priority --json`); + } + commands.push(`codegraph doctor "${normalizePathForDisplay(defaultCacheIndexPath(projectRoot))}"`); + return commands; +} + +async function buildInspectReport( + projectRoot: string, + includeRoots: string[], + files: string[], + discovery: ProjectFileDiscoveryOptions, + graphOptions: GraphBuildOptions | undefined, + cache: CacheMode | undefined, + nativeMode: NativeRuntimeMode, + workerOpts: { useNativeWorkers: true } | Record, + progressHandler: ((update: { current: number; total: number }) => void) | undefined, + limit: number, + writeStderrLine: (message: string) => void, +): Promise { + const { graph, indexCache } = await buildScopedReportGraph(projectRoot, includeRoots, files, { + ...(cache ? { cache } : {}), + discovery, + ...(graphOptions ? { graphOptions } : {}), + nativeMode, + workerOpts, + ...(progressHandler ? { progressHandler } : {}), + writeStderrLine, + }); + const hotspots = getHotspots(graph, { limit }); + const unresolved = getUnresolvedImports(graph, { projectRoot }); + const cycles = sortDetailedCycles(findDetailedCycles(graph), "priority"); + const loadError = getNativeTreeSitterLoadError(nativeMode); + return { + root: normalizePathForDisplay(projectRoot), + includeRoots: includeRoots.map(normalizePathForDisplay), + ...(indexCache ? { indexCache } : {}), + backend: { + native: { + available: isNativeTreeSitterAvailable(nativeMode), + ...(loadError ? { loadError: String(loadError) } : {}), + supportedLanguageIds: getNativeTreeSitterSupportedLanguageIds(nativeMode), + }, + }, + files: { + total: files.length, + byLanguage: countFilesByLanguage(files), + }, + hotspots, + unresolved: { + total: unresolved.length, + top: unresolved.slice(0, limit).map((entry) => ({ + name: entry.name, + importerCount: entry.importers.length, + })), + }, + cycles: { + total: cycles.length, + top: cycles.slice(0, limit).map((cycle) => ({ + files: cycle.files.map(normalizePathForDisplay), + priorityScore: cycle.priorityScore, + size: cycle.files.length, + })), + }, + recommendedCommands: buildRecommendedInspectCommands( + projectRoot, + includeRoots, + !!cycles.length, + !!unresolved.length, + ), + }; +} + +export async function handleInspectCommand(context: InspectCommandContext): Promise { + const cache = parseCacheModeOption(context.getOpt("--cache")); + const limit = parsePositiveIntegerOption(context.getOpt("--limit"), "--limit", 20); + const files = await context.resolveFilesFromRoots(); + const report = await buildInspectReport( + context.projectRootFs, + context.includeRootsAbs, + files, + context.discoveryOptions, + context.graphOptions, + cache, + context.nativeMode, + context.workerOpts, + context.progressHandler, + limit, + context.writeStderrLine, + ); + context.writeJSONLine(report); +} + +export async function handleHotspotsCommand(context: InspectCommandContext): Promise { + const json = context.hasFlag("--json"); + const cache = parseCacheModeOption(context.getOpt("--cache")); + const limit = parsePositiveIntegerOption(context.getOpt("--limit"), "--limit", 20); + const files = await context.resolveFilesFromRoots(); + const { graph } = await buildScopedReportGraph(context.projectRootFs, context.includeRootsAbs, files, { + ...(cache ? { cache } : {}), + discovery: context.discoveryOptions, + ...(context.graphOptions ? { graphOptions: context.graphOptions } : {}), + nativeMode: context.nativeMode, + workerOpts: context.workerOpts, + ...(context.progressHandler ? { progressHandler: context.progressHandler } : {}), + writeStderrLine: context.writeStderrLine, + }); + const hotspots = getHotspots(graph, { limit }); + + if (json) { + context.writeJSONLine(hotspots); + return; + } + context.writeStdoutLine("Top hotspots (files with high fan-in/out):"); + for (const item of hotspots) { + context.writeStdoutLine( + `- ${path.relative(context.projectRootFs, item.file)} (fan-in: ${item.fanIn}, fan-out: ${item.fanOut}, score: ${item.score.toFixed(1)})`, + ); + } +} diff --git a/src/cli/navigation.ts b/src/cli/navigation.ts new file mode 100644 index 00000000..53b7a2c9 --- /dev/null +++ b/src/cli/navigation.ts @@ -0,0 +1,157 @@ +import path from "node:path"; +import { buildProjectIndex, findReferences, goToDefinition } from "../indexer.js"; +import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; +import { assertFilePathWithinRoot, type ProjectFileDiscoveryOptions } from "../util.js"; +import { parsePositiveIntegerOption } from "./options.js"; + +type CliProjectFileInput = + | { status: "ok"; file: string } + | { status: "error"; reason: "outside_project_root"; error: string }; + +export type NavigationCommandContext = { + projectRootFs: string; + discoveryOptions: ProjectFileDiscoveryOptions; + positionals: string[]; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + nativeMode: NativeRuntimeMode; + workerOpts: { useNativeWorkers: true } | Record; + progressHandler: ((update: { current: number; total: number }) => void) | undefined; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; + writeStderrLine: (message: string) => void; + exit: (code: number) => never; +}; + +function resolveCliProjectFile(projectRoot: string, fileArg: string, label: string): CliProjectFileInput { + try { + return { + status: "ok", + file: assertFilePathWithinRoot(projectRoot, fileArg, label), + }; + } catch (error) { + return { + status: "error", + reason: "outside_project_root", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function writeCliProjectFileError( + context: Pick, + result: Extract, + output: "json" | "text" = "json", +): void { + if (output === "json") { + context.writeJSONLine(result); + return; + } + context.writeStdoutLine(`error: ${result.reason}: ${result.error}`); +} + +function indexOptions(context: NavigationCommandContext) { + return { + onProgress: context.progressHandler, + discovery: context.discoveryOptions, + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...context.workerOpts, + }; +} + +export async function handleDumpmodCommand(context: NavigationCommandContext): Promise { + const [fileArg] = context.positionals; + if (!fileArg) { + context.writeStderrLine("Usage: dumpmod "); + context.exit(2); + } + const resolvedFile = resolveCliProjectFile(context.projectRootFs, fileArg, "File"); + if (resolvedFile.status === "error") { + writeCliProjectFileError(context, resolvedFile); + return; + } + const file = resolvedFile.file; + const index = await buildProjectIndex(context.projectRootFs, indexOptions(context)); + const mod = index.byFile.get(file); + if (!mod) { + context.writeJSONLine({ + status: "not_found", + reason: "Module not indexed", + file, + }); + return; + } + context.writeJSONLine({ + file, + locals: mod.locals.map((l) => ({ + name: l.localName, + kind: l.kind, + start: l.range.start, + })), + exports: mod.exports.map((e) => + e.type === "local" + ? { + type: e.type, + exportedAs: e.exportedAs, + def: { + name: e.target.localName, + kind: e.target.kind, + start: e.target.range.start, + }, + } + : e, + ), + imports: mod.imports, + }); +} + +export async function handleGotoCommand(context: NavigationCommandContext): Promise { + const [fileArg, lineArg, colArg] = context.positionals; + if (!fileArg || !lineArg || !colArg) { + context.writeStderrLine("Usage: goto "); + context.exit(2); + } + const resolvedFile = resolveCliProjectFile(context.projectRootFs, fileArg, "File"); + if (resolvedFile.status === "error") { + writeCliProjectFileError(context, resolvedFile); + return; + } + const line = parsePositiveIntegerOption(lineArg, "line", 1); + const column = parsePositiveIntegerOption(colArg, "column", 1); + const index = await buildProjectIndex(context.projectRootFs, indexOptions(context)); + const res = await goToDefinition(index, { file: resolvedFile.file, line, column }); + context.writeJSONLine(res); +} + +export async function handleRefsCommand(context: NavigationCommandContext): Promise { + const fileArg = context.getOpt("--file"); + const lineArg = context.getOpt("--line"); + const colArg = context.getOpt("--col") ?? context.getOpt("--column"); + if (!fileArg || !lineArg || !colArg) { + context.writeStderrLine("Usage: refs --file --line --col "); + context.exit(2); + } + const line = parsePositiveIntegerOption(lineArg, "--line", 1); + const column = parsePositiveIntegerOption(colArg, "--col", 1); + const pretty = context.hasFlag("--pretty"); + const resolvedFile = resolveCliProjectFile(context.projectRootFs, fileArg, "File"); + if (resolvedFile.status === "error") { + writeCliProjectFileError(context, resolvedFile, pretty ? "text" : "json"); + return; + } + const index = await buildProjectIndex(context.projectRootFs, indexOptions(context)); + const res = await findReferences(index, { file: resolvedFile.file, line, column }); + if (!pretty) { + context.writeJSONLine(res); + return; + } + if (res.status === "ok") { + for (const r of res.references) { + const rel = path.relative(context.projectRootFs, r.file); + const { line: refLine, column: refColumn } = r.range.start; + context.writeStdoutLine(`${rel}:${refLine}:${refColumn}`); + } + return; + } + context.writeStdoutLine(`not_found: ${res.reason}`); +} From 1bd12fe964f0fdd19b2edc9f1c895d1e3f1cbc85 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 17:19:34 -0400 Subject: [PATCH 06/25] Extract CLI graph and runtime context --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/cli.ts | 775 +++------------------------------------- src/cli/context.ts | 321 +++++++++++++++++ src/cli/graph.ts | 486 +++++++++++++++++++++++++ 4 files changed, 854 insertions(+), 730 deletions(-) create mode 100644 src/cli/context.ts create mode 100644 src/cli/graph.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 556eefae..1370d4e1 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -51,7 +51,7 @@ No dependency cycles were reported. The highest remaining concentration is in: ### Decomposition And Surface Area -- [ ] Extract the remaining command handlers from `src/cli.ts`. +- [x] Extract the remaining command handlers from `src/cli.ts`. - Current shape: `runCliWithActiveRuntime` still owns command context creation, root/discovery resolution, changed-file resolution, graph output, index output, `dumpmod`, `goto`, `refs`, `grep`, `inspect`, and `hotspots`. - Target split: - `src/cli/context.ts` for parsed args, runtime writers, root/discovery/include-root resolution, progress setup, and shared stdin/report helpers. diff --git a/src/cli.ts b/src/cli.ts index cd048e7d..df954a3a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,33 +1,32 @@ #!/usr/bin/env node -import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import fs from "node:fs"; -import fsp from "node:fs/promises"; -import { performance } from "node:perf_hooks"; import { fileURLToPath } from "node:url"; -import picomatch from "picomatch"; -import { buildProjectIndex, buildProjectIndexFromFiles, buildProjectIndexIncremental } from "./indexer.js"; -import type { BuildOptions, BuildReport } from "./indexer/types.js"; -import type { ReviewBuildReport } from "./review.js"; -import { - collectGraph, - graphToMermaid, - graphToDOT, - buildSymbolGraph, - buildSymbolGraphDetailed, - graphToMermaidSymbols, - graphToDOTSymbols, - graphToMermaidSymbolsWithFiles, - graphToDOTSymbolsWithFiles, - type GraphBuildOptions, - type SymbolGraph, -} from "./graphs.js"; -import { writeGraphSqlite, updateGraphSqlite } from "./sqlite.js"; +import type { BuildOptions } from "./indexer/types.js"; +import { type GraphBuildOptions } from "./graphs.js"; import { type NativeRuntimeMode } from "./native/treeSitterNative.js"; import { handleChunkCommand } from "./cli/chunk.js"; +import { + createCliProgressHandler, + exitCli, + filterFilesByCliDiscoveryGlobs, + getCwd, + maybeWriteNativeBackendStatus, + parseCliArgs, + runWithCliRuntime, + setCliStderrFilePath, + writeCommandReport, + writeError, + writeJSONLine, + writeStderrLine, + writeStdoutLine, + type CliRuntime, + type CommandReport, +} from "./cli/context.js"; import { handleArtifactCommand } from "./cli/artifact.js"; import { buildDoctorReport } from "./cli/doctor.js"; import { handleExplainCommand } from "./cli/explain.js"; +import { handleGraphCommand } from "./cli/graph.js"; import { handleGraphDeltaCommand } from "./cli/graphDelta.js"; import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { handleGrepCommand } from "./cli/grep.js"; @@ -37,20 +36,12 @@ import { handleIndexCommand } from "./cli/index.js"; import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleMcpServeCommand } from "./cli/mcp.js"; import { handleDumpmodCommand, handleGotoCommand, handleRefsCommand } from "./cli/navigation.js"; -import { - isCliValueOption, - parseCacheModeOption, - parseNonNegativeIntegerOption, - parseOptionalNonNegativeIntegerOption, -} from "./cli/options.js"; import { getCodegraphPackageIdentity, getCodegraphVersion } from "./cli/packageInfo.js"; import { handleReviewCommand } from "./cli/review.js"; import { handleSearchCommand } from "./cli/search.js"; import { handleSkillCommand } from "./cli/skill.js"; import { handleSqlCommand } from "./cli/sql.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "./config.js"; -import { buildSqlArtifactGraphFromFiles } from "./sql/index.js"; -import type { Graph } from "./types.js"; import { listChangedFiles, listProjectFiles, @@ -58,191 +49,8 @@ import { resolveFilePathFromRoot, type ProjectFileDiscoveryOptions, } from "./util.js"; -import { isRelativePathInside } from "./util/projectFiles.js"; - -function toJSON(obj: unknown): string { - return JSON.stringify(obj, null, 2); -} - -function normalizeCliGlobPattern(globPattern: string): string { - return globPattern.trim().replace(/\\/g, "/"); -} - -export function isCliDiscoveryRelativePathInside(relativePath: string): boolean { - return isRelativePathInside(relativePath); -} - -function matchesCliDiscoveryGlob( - absolutePath: string, - scanRoot: string, - matcher: (relativePath: string) => boolean, -): boolean { - const relativePath = path.relative(scanRoot, absolutePath); - if (!isCliDiscoveryRelativePathInside(relativePath)) { - return false; - } - return matcher(normalizePath(relativePath)); -} - -function filterFilesByCliDiscoveryGlobs( - files: readonly string[], - scanRoot: string, - discovery: ProjectFileDiscoveryOptions, -): string[] { - const includeMatchers = (discovery.includeGlobs ?? []) - .map(normalizeCliGlobPattern) - .filter(Boolean) - .map((globPattern) => picomatch(globPattern, { dot: true })); - const ignoreMatchers = (discovery.ignoreGlobs ?? []) - .map(normalizeCliGlobPattern) - .filter(Boolean) - .map((globPattern) => picomatch(globPattern, { dot: true })); - - if (!includeMatchers.length && !ignoreMatchers.length) { - return [...files]; - } - - return files.filter((filePath) => { - if ( - includeMatchers.length && - !includeMatchers.some((matcher) => matchesCliDiscoveryGlob(filePath, scanRoot, matcher)) - ) { - return false; - } - return !ignoreMatchers.some((matcher) => matchesCliDiscoveryGlob(filePath, scanRoot, matcher)); - }); -} - -export type CliRuntime = { - stdout: (chunk: string) => void; - stderr: (chunk: string) => void; - exit: (code: number) => never; - cwd: () => string; -}; - -function createDefaultCliRuntime(): CliRuntime { - return { - stdout: (chunk) => process.stdout.write(chunk), - stderr: (chunk) => process.stderr.write(chunk), - exit: (code) => process.exit(code), - cwd: () => process.cwd(), - }; -} - -type CliContext = { - runtime: CliRuntime; - stderrFilePath: string | undefined; -}; - -const defaultCliContext: CliContext = { - runtime: createDefaultCliRuntime(), - stderrFilePath: undefined, -}; -const cliContextStorage = new AsyncLocalStorage(); - -function getCliContext(): CliContext { - return cliContextStorage.getStore() ?? defaultCliContext; -} - -function createCliContext(runtime: Partial = {}): CliContext { - return { - runtime: { ...createDefaultCliRuntime(), ...runtime }, - stderrFilePath: undefined, - }; -} - -function getCwd(): string { - return getCliContext().runtime.cwd(); -} - -function exitCli(code: number): never { - return getCliContext().runtime.exit(code); -} - -function writeStdoutLine(message: string) { - getCliContext().runtime.stdout(`${message}\n`); -} -function writeJSONLine(value: unknown) { - writeStdoutLine(toJSON(value)); -} -function writeStderrLine(message: string) { - const context = getCliContext(); - context.runtime.stderr(`${message}\n`); - try { - if (context.stderrFilePath) - fs.appendFileSync(context.stderrFilePath, `${message}\n`, { - encoding: "utf8", - }); - } catch { - // Swallow file logging errors to avoid masking primary error output - } -} -function writeError(error: unknown) { - if (error instanceof Error) { - writeStderrLine(error.stack ?? error.message); - return; - } - writeStderrLine(String(error)); -} -function formatNativeBackendStatus(report: BuildReport | undefined): string | undefined { - const native = report?.backend?.native; - if (!native) return undefined; - if (native.filesUsed > 0) { - if (native.filesFellBack > 0) { - return `Backend: native tree-sitter used for ${native.filesUsed} file(s); fallback for ${native.filesFellBack} file(s)`; - } - return `Backend: native tree-sitter used for ${native.filesUsed} file(s)`; - } - const fallbackTotal = native.filesFellBack; - if (native.available) { - if (fallbackTotal > 0) { - return `Backend: JS tree-sitter fallback for ${fallbackTotal} file(s)`; - } - return "Backend: native tree-sitter available"; - } - const reason = native.loadError ? ` (${native.loadError})` : ""; - return `Backend: JS tree-sitter fallback; native addon unavailable${reason}`; -} - -function formatNativeBackendFallbackSummary(report: BuildReport | undefined): string | undefined { - const native = report?.backend?.native; - if (!native || native.filesFellBack === 0) return undefined; - const parts = Object.entries(native.byLanguage) - .filter(([, entry]) => entry.filesFellBack > 0) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([languageId, entry]) => { - const reasonSummary = Object.entries(entry.fallbackReasons) - .filter(([, count]) => count > 0) - .map(([reason, count]) => `${reason}=${count}`) - .join(","); - return reasonSummary.length ? `${languageId}(${reasonSummary})` : `${languageId}(${entry.filesFellBack})`; - }); - if (!parts.length) return undefined; - return `Native fallback summary: ${parts.join(", ")}`; -} - -function formatParserBackendSummary(report: BuildReport | undefined): string | undefined { - const parser = report?.backend?.parser; - if (!parser || parser.total === 0) return undefined; - const parts = Object.entries(parser.byLanguage) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([languageId, count]) => `${languageId}(${count})`); - if (!parts.length) { - return `Parser backend degradation: ${parser.total} file(s)`; - } - return `Parser backend degradation: ${parser.total} file(s) [${parts.join(", ")}]`; -} - -function maybeWriteNativeBackendStatus(report: BuildReport | undefined, showProgress: boolean): void { - if (!showProgress) return; - const message = formatNativeBackendStatus(report); - if (message) writeStderrLine(message); - const summary = formatNativeBackendFallbackSummary(report); - if (summary) writeStderrLine(summary); - const parserSummary = formatParserBackendSummary(report); - if (parserSummary) writeStderrLine(parserSummary); -} +export { isCliDiscoveryRelativePathInside } from "./cli/context.js"; function normalizeEntrypointPath(filePath: string): string { const resolvedPath = path.resolve(filePath); @@ -266,260 +74,6 @@ function isDirectCliExecution(importMetaUrl: string, argv: string[] = process.ar return modulePath === invokedPath; } -type CommandTimingReport = { - totalMs?: number; - resolveFilesMs?: number; - commandMs?: number; -}; - -type CommandReport = { - command: string; - timings: CommandTimingReport; - index?: BuildReport; - review?: ReviewBuildReport; -}; - -type ParsedCliArgs = { - positionals: string[]; - flags: Set; - options: Map; -}; - -function parseCliArgs(command: string, tokens: string[]): ParsedCliArgs { - const positionals: string[] = []; - const flags = new Set(); - const options = new Map(); - - const pushOpt = (key: string, value: string) => { - const existing = options.get(key); - if (existing) existing.push(value); - else options.set(key, [value]); - }; - - for (let i = 0; i < tokens.length; i++) { - const t = tokens[i]!; - if (t === "--") { - positionals.push(...tokens.slice(i + 1)); - break; - } - - if (t.startsWith("--")) { - const eq = t.indexOf("="); - if (eq !== -1) { - const key = t.slice(0, eq); - const value = t.slice(eq + 1); - pushOpt(key, value); - continue; - } - const key = t; - if (isCliValueOption(command, key, positionals)) { - const next = tokens[i + 1]; - if (next === undefined) throw new Error(`Missing value for ${key} option`); - pushOpt(key, next); - i++; - } else { - flags.add(key); - } - continue; - } - - if (t.startsWith("-") && t.length > 1) { - // Support a minimal set of short options. Everything else is treated as a boolean flag. - if (t === "-o") { - const next = tokens[i + 1]; - if (!next || next.startsWith("-")) throw new Error("Missing value for -o/--output"); - pushOpt("--output", next); - i++; - continue; - } - flags.add(t); - continue; - } - - positionals.push(t); - } - - return { positionals, flags, options }; -} - -async function writeCommandReport(report: CommandReport, reportFile: string | undefined) { - const payload = JSON.stringify(report, null, 2); - if (reportFile) { - const resolved = normalizePath(resolveFilePathFromRoot(getCwd(), reportFile)); - await fsp.writeFile(resolved, `${payload}\n`, "utf8"); - } else { - writeStderrLine(payload); - } -} - -// Compact JSON helpers to reduce repeated strings in graph output -type CompactEdgeTo = { type: "file"; path: number } | { type: "external"; name: string }; -type CompactFileEdge = { - from: number; - to: CompactEdgeTo; - raw: string; - typeOnly?: boolean; -}; -type CompactSymbolEdge = { from: number; to: number; label?: string }; - -function compactGraphWithSymbols(fgraph: Graph, sgraph: SymbolGraph, stable = false) { - const files = [...fgraph.nodes]; - if (stable) files.sort(); - const fileIndex = new Map(); - for (let i = 0; i < files.length; i++) fileIndex.set(files[i]!, i); - - const fileEdges: CompactFileEdge[] = fgraph.edges.map((e) => ({ - from: fileIndex.get(e.from)!, - to: - e.to?.type === "file" - ? { type: "file" as const, path: fileIndex.get(e.to.path)! } - : { type: "external" as const, name: e.to.name }, - raw: e.raw, - ...(e.typeOnly !== undefined ? { typeOnly: e.typeOnly } : {}), - })); - if (stable) { - const toKey = (to: CompactEdgeTo) => (to?.type === "file" ? `file:${to.path}` : `ext:${to?.name ?? ""}`); - fileEdges.sort((a, b) => { - const byFrom = a.from - b.from; - if (byFrom) return byFrom; - const ak = toKey(a.to); - const bk = toKey(b.to); - if (ak !== bk) return ak < bk ? -1 : 1; - const ar = String(a.raw ?? ""); - const br = String(b.raw ?? ""); - if (ar !== br) return ar < br ? -1 : 1; - return Number(!!a.typeOnly) - Number(!!b.typeOnly); - }); - } - - const symbolIds = [...sgraph.nodes.keys()]; - if (stable) symbolIds.sort(); - const symbolIndex = new Map(); - for (let i = 0; i < symbolIds.length; i++) symbolIndex.set(symbolIds[i]!, i); - - const symbols = symbolIds.map((id) => { - const n = sgraph.nodes.get(id)!; - return { - id: symbolIndex.get(id)!, - file: fileIndex.get(n.file)!, - name: n.name, - kind: n.kind, - }; - }); - - const symbolEdges: CompactSymbolEdge[] = sgraph.edges.map((e) => ({ - from: symbolIndex.get(e.from)!, - to: symbolIndex.get(e.to)!, - ...(e.label ? { label: e.label } : {}), - })); - if (stable) { - symbolEdges.sort((a, b) => { - const byFrom = a.from - b.from; - if (byFrom) return byFrom; - const byTo = a.to - b.to; - if (byTo) return byTo; - const al = String(a.label ?? ""); - const bl = String(b.label ?? ""); - if (al !== bl) return al < bl ? -1 : 1; - return 0; - }); - } - - return { - files, - fileEdges, - symbols, - symbolEdges, - symbolIdIndex: symbolIds, - }; -} - -function compactSymbolsOnly(allFiles: string[], sgraph: SymbolGraph, stable = false) { - const files = [...allFiles]; - if (stable) files.sort(); - const fileIndex = new Map(); - for (let i = 0; i < files.length; i++) fileIndex.set(files[i]!, i); - - const symbolIds = [...sgraph.nodes.keys()]; - if (stable) symbolIds.sort(); - const symbolIndex = new Map(); - for (let i = 0; i < symbolIds.length; i++) symbolIndex.set(symbolIds[i]!, i); - - const symbols = symbolIds.map((id) => { - const n = sgraph.nodes.get(id)!; - return { - id: symbolIndex.get(id)!, - file: fileIndex.get(n.file)!, - name: n.name, - kind: n.kind, - }; - }); - - const symbolEdges: CompactSymbolEdge[] = sgraph.edges.map((e) => ({ - from: symbolIndex.get(e.from)!, - to: symbolIndex.get(e.to)!, - ...(e.label ? { label: e.label } : {}), - })); - if (stable) { - symbolEdges.sort((a, b) => { - const byFrom = a.from - b.from; - if (byFrom) return byFrom; - const byTo = a.to - b.to; - if (byTo) return byTo; - const al = String(a.label ?? ""); - const bl = String(b.label ?? ""); - if (al !== bl) return al < bl ? -1 : 1; - return 0; - }); - } - - return { - files, - symbols, - symbolEdges, - symbolIdIndex: symbolIds, - }; -} - -function stabilizeGraph(graph: Graph): Graph { - const nodes = [...graph.nodes].slice().sort(); - const edges = [...graph.edges].slice().sort((a, b) => { - const af = String(a.from); - const bf = String(b.from); - if (af !== bf) return af < bf ? -1 : 1; - const at = a.to.type === "file" ? `file:${a.to.path}` : `ext:${a.to.name ?? ""}`; - const bt = b.to.type === "file" ? `file:${b.to.path}` : `ext:${b.to.name ?? ""}`; - if (at !== bt) return at < bt ? -1 : 1; - const ar = String(a.raw ?? ""); - const br = String(b.raw ?? ""); - if (ar !== br) return ar < br ? -1 : 1; - return Number(!!a.typeOnly) - Number(!!b.typeOnly); - }); - return { nodes: new Set(nodes), edges }; -} - -function stabilizeSymbolGraph(graph: SymbolGraph): SymbolGraph { - const nodeEntries = [...graph.nodes.entries()].slice().sort((a, b) => { - const ak = a[0]; - const bk = b[0]; - if (ak !== bk) return ak < bk ? -1 : 1; - return 0; - }); - const edges = [...graph.edges].slice().sort((a, b) => { - const af = String(a.from); - const bf = String(b.from); - if (af !== bf) return af < bf ? -1 : 1; - const at = String(a.to); - const bt = String(b.to); - if (at !== bt) return at < bt ? -1 : 1; - const al = String(a.label ?? ""); - const bl = String(b.label ?? ""); - if (al !== bl) return al < bl ? -1 : 1; - return 0; - }); - return { nodes: new Map(nodeEntries), edges }; -} - function parseNativeRuntimeMode(value: string | undefined): NativeRuntimeMode { if (value === undefined) return "auto"; if (value === "auto" || value === "on" || value === "off") { @@ -567,25 +121,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { const useNativeWorkers = hasFlag("--workers"); const workerOpts = useNativeWorkers ? ({ useNativeWorkers: true } as const) : ({} as const); const showProgress = hasFlag("--progress"); - let lastProgressUpdate = 0; - function handleIndexingProgress(update: { current: number; total: number }): void { - const now = Date.now(); - const isComplete = update.current === update.total; - const shouldUpdate = isComplete || now - lastProgressUpdate > 100; - - if (shouldUpdate) { - if (process.stderr.isTTY) { - getCliContext().runtime.stderr(`\r[Progress] ${update.current}/${update.total} files processed...`); - if (isComplete) { - getCliContext().runtime.stderr("\n"); - } - } else if (update.current === 1 || isComplete || update.current % 100 === 0) { - getCliContext().runtime.stderr(`[Progress] ${update.current}/${update.total} files processed.\n`); - } - lastProgressUpdate = now; - } - } - const progressHandler = showProgress ? handleIndexingProgress : undefined; + const progressHandler = createCliProgressHandler(showProgress); const graphFlags = { fast: hasFlag("--fast-graph"), resolveNodeModules: hasFlag("--resolve-node-modules"), @@ -874,246 +410,29 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { } if (cmd === "graph") { - const commandReport: CommandReport | undefined = reportEnabled ? { command: "graph", timings: {} } : undefined; - const commandStart = performance.now(); - const resolveStart = performance.now(); - const files = await resolveFiles(); - if (commandReport) { - commandReport.timings.resolveFilesMs = Math.round(performance.now() - resolveStart); - } - const hasExplicitSymbolFlag = hasFlag("--symbols") || hasFlag("--symbols-only") || hasFlag("--symbols-detailed"); - const hasExplicitFormatFlag = hasFlag("--mermaid") || hasFlag("--dot") || hasFlag("--json"); - const outputArg = getOpt("--output"); - const sqliteArg = getOpt("--sqlite"); - const stderrArg = getOpt("--stderr-file"); - const stdoutMode = hasFlag("--stdout"); - const defaultGraphMode = !hasExplicitSymbolFlag && !hasExplicitFormatFlag; - - const wantSymbols = hasExplicitSymbolFlag; - const detailedSymbols = hasFlag("--symbols-detailed"); - const threads = parseNonNegativeIntegerOption(getOpt("--threads"), "--threads", 0); - const cache = parseCacheModeOption(getOpt("--cache")); - const cacheStrict = hasFlag("--cache-strict"); - const stable = hasFlag("--stable"); - let format: "mermaid" | "dot" | "json" = "json"; - if (hasFlag("--mermaid")) { - format = "mermaid"; - } else if (hasFlag("--dot")) { - format = "dot"; - } - const fast = graphFlags.fast; - const resolveNodeModules = graphFlags.resolveNodeModules; - const dynamicImportHeuristics = graphFlags.dynamicImportHeuristics; - const resolutionHints = graphFlags.resolutionHints; - const compact = defaultGraphMode || hasFlag("--compact-json"); - const includeSqlArtifacts = hasFlag("--sql-artifacts"); - let outputFile: string | undefined; - if (outputArg) { - outputFile = normalizePath(resolveFilePathFromRoot(getCwd(), outputArg)); - } else if (defaultGraphMode && !stdoutMode) { - outputFile = path.resolve(getCwd(), "codegraph.json").replace(/\\/g, "/"); - } - const sqliteFile = sqliteArg ? normalizePath(resolveFilePathFromRoot(getCwd(), sqliteArg)) : undefined; - if (stderrArg) { - getCliContext().stderrFilePath = normalizePath(resolveFilePathFromRoot(getCwd(), stderrArg)); - } else if (defaultGraphMode) { - getCliContext().stderrFilePath = path.resolve(getCwd(), "codegraph.err").replace(/\\/g, "/"); - } else { - getCliContext().stderrFilePath = undefined; - } - - const finalizeReport = async () => { - if (!commandReport) return; - commandReport.timings.commandMs = Math.round(performance.now() - commandStart); - commandReport.timings.totalMs = commandReport.timings.commandMs; - await writeCommandReport(commandReport, reportFile); - }; - - const writeOut = async (text: string) => { - if (outputFile) { - await fsp.writeFile(outputFile, `${text}\n`, "utf8"); - } else { - writeStdoutLine(text); - } - }; - const indexReport: BuildReport | undefined = reportEnabled || showProgress ? { timings: {} } : undefined; - if (commandReport && indexReport) { - commandReport.index = indexReport; - } - if (sqliteFile) { - const changedSet = await resolveChangedFilesWithDeletes(); - const graphOptions = { - fast, - resolveNodeModules, - dynamicImportHeuristics, - ...(resolutionHints.length ? { resolutionHints } : {}), - }; - const sqliteCacheMode = cache ?? (changedSet ? "disk" : undefined); - const index = changedSet - ? await buildProjectIndexIncremental(projectRootFs, { - onProgress: progressHandler, - threads, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, - ...(sqliteCacheMode !== undefined ? { cache: sqliteCacheMode } : {}), - cacheStrict, - files: changedSet.existingFiles, - ...(gitBase ? { gitBase } : {}), - ...(gitHead ? { gitHead } : {}), - ...(changedSince ? { changedSince } : {}), - graph: graphOptions, - ...(indexReport ? { report: indexReport } : {}), - }) - : await buildProjectIndexFromFiles(projectRootFs, files, { - onProgress: progressHandler, - threads, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, - ...(sqliteCacheMode !== undefined ? { cache: sqliteCacheMode } : {}), - cacheStrict, - graph: graphOptions, - ...(indexReport ? { report: indexReport } : {}), - }); - maybeWriteNativeBackendStatus(indexReport, showProgress); - - const detailedSymbols = hasFlag("--symbols-detailed"); - const scope = getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined; - const maxEdgesRaw = getOpt("--symbols-detailed-max-edges"); - const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges"); - const membersOnly = hasFlag("--symbols-detailed-members-only"); - const sgraph = detailedSymbols - ? await buildSymbolGraphDetailed(index, { - ...(scope !== undefined ? { scope } : {}), - ...(typeof maxEdges === "number" ? { maxEdges } : {}), - membersOnly, - }) - : await buildSymbolGraph(index); - - const sqliteDbExists = fs.existsSync(sqliteFile); - if (changedSet && sqliteDbExists) { - await updateGraphSqlite({ - fileGraph: index.graph, - symbolGraph: sgraph, - outputPath: sqliteFile, - changedFiles: changedSet.existingFiles, - deletedFiles: changedSet.deletedFiles, - fullGraphSync: true, - }); - } else { - await writeGraphSqlite({ - fileGraph: index.graph, - symbolGraph: sgraph, - outputPath: sqliteFile, - }); - } - await finalizeReport(); - return; - } - if (wantSymbols) { - const index = await buildProjectIndexFromFiles(projectRootFs, files, { - onProgress: progressHandler, - threads, - discovery: discoveryOptions, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...workerOpts, - ...(cache !== undefined ? { cache } : {}), - cacheStrict, - graph: { - fast, - resolveNodeModules, - dynamicImportHeuristics, - ...(resolutionHints.length ? { resolutionHints } : {}), - }, - ...(indexReport ? { report: indexReport } : {}), - }); - maybeWriteNativeBackendStatus(indexReport, showProgress); - let sgraph; - if (detailedSymbols) { - const scope = getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined; - const maxEdgesRaw = getOpt("--symbols-detailed-max-edges"); - const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges"); - const membersOnly = hasFlag("--symbols-detailed-members-only"); - sgraph = await buildSymbolGraphDetailed(index, { - ...(scope !== undefined ? { scope } : {}), - ...(typeof maxEdges === "number" ? { maxEdges } : {}), - membersOnly, - }); - } else { - sgraph = await buildSymbolGraph(index); - } - const sgraphOut = stable ? stabilizeSymbolGraph(sgraph) : sgraph; - if (hasFlag("--symbols-only")) { - if (format === "mermaid") { - await writeOut(graphToMermaidSymbols(sgraphOut, projectRootFs)); - } else if (format === "dot") { - await writeOut(graphToDOTSymbols(sgraphOut, projectRootFs)); - } else { - if (compact) { - const allFiles = [...index.graph.nodes]; - await writeOut(toJSON(compactSymbolsOnly(allFiles, sgraphOut, stable))); - } else { - await writeOut( - toJSON({ - nodes: [...sgraphOut.nodes.values()], - edges: sgraphOut.edges, - }), - ); - } - } - await finalizeReport(); - return; - } - // Reuse the graph already built during indexing to avoid an extra pass - const fgraph = index.graph; - const fgraphOut = stable ? stabilizeGraph(fgraph) : fgraph; - if (format === "mermaid") { - await writeOut(graphToMermaidSymbolsWithFiles(sgraphOut, fgraphOut, projectRootFs)); - } else if (format === "dot") { - await writeOut(graphToDOTSymbolsWithFiles(sgraphOut, fgraphOut, projectRootFs)); - } else { - if (compact) { - await writeOut(toJSON(compactGraphWithSymbols(fgraphOut, sgraphOut, stable))); - } else { - await writeOut( - toJSON({ - files: [...fgraphOut.nodes], - fileEdges: fgraphOut.edges, - symbols: [...sgraphOut.nodes.values()], - symbolEdges: sgraphOut.edges, - }), - ); - } - } - await finalizeReport(); - return; - } - const graph = await collectGraph(projectRootFs, files, { - fast, - threads, - resolveNodeModules, - dynamicImportHeuristics, - ...(nativeMode !== "auto" ? { native: nativeMode } : {}), - ...(resolutionHints.length ? { resolutionHints } : {}), - ...(indexReport ? { report: indexReport } : {}), + await handleGraphCommand({ + projectRootFs, + discoveryOptions, + nativeMode, + workerOpts, + progressHandler, + graphFlags, + gitBase, + gitHead, + changedSince, + reportEnabled, + reportFile, + showProgress, + getOpt, + hasFlag, + cwd: getCwd, + resolveFiles, + resolveChangedFilesWithDeletes, + writeStdoutLine, + setStderrFilePath: setCliStderrFilePath, + writeCommandReport, + maybeWriteNativeBackendStatus, }); - maybeWriteNativeBackendStatus(indexReport, showProgress); - const graphOut = stable ? stabilizeGraph(graph) : graph; - if (format === "mermaid") await writeOut(graphToMermaid(graphOut)); - else if (format === "dot") await writeOut(graphToDOT(graphOut)); - else { - const sqlFiles = includeSqlArtifacts ? files.filter((file) => path.extname(file).toLowerCase() === ".sql") : []; - const sqlArtifacts = sqlFiles.length ? await buildSqlArtifactGraphFromFiles(sqlFiles) : undefined; - await writeOut( - toJSON({ - nodes: [...graphOut.nodes], - edges: graphOut.edges, - ...(sqlArtifacts ? { sqlArtifacts } : {}), - }), - ); - } - await finalizeReport(); return; } @@ -1383,13 +702,11 @@ export async function runCli( rawArgs: string[] = process.argv.slice(2), runtime: Partial = {}, ): Promise { - const context = createCliContext(runtime); - await cliContextStorage.run(context, async () => await runCliWithActiveRuntime(rawArgs)); + await runWithCliRuntime(runtime, async () => await runCliWithActiveRuntime(rawArgs)); } if (isDirectCliExecution(import.meta.url)) { - const context = createCliContext(); - void cliContextStorage.run(context, async () => { + void runWithCliRuntime({}, async () => { try { await runCliWithActiveRuntime(process.argv.slice(2)); } catch (error) { diff --git a/src/cli/context.ts b/src/cli/context.ts new file mode 100644 index 00000000..9ce22dc9 --- /dev/null +++ b/src/cli/context.ts @@ -0,0 +1,321 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import picomatch from "picomatch"; +import type { BuildReport } from "../indexer/types.js"; +import type { ReviewBuildReport } from "../review.js"; +import { normalizePath, resolveFilePathFromRoot, type ProjectFileDiscoveryOptions } from "../util.js"; +import { isRelativePathInside } from "../util/projectFiles.js"; +import { isCliValueOption } from "./options.js"; + +function toJSON(obj: unknown): string { + return JSON.stringify(obj, null, 2); +} + +function normalizeCliGlobPattern(globPattern: string): string { + return globPattern.trim().replace(/\\/g, "/"); +} + +export function isCliDiscoveryRelativePathInside(relativePath: string): boolean { + return isRelativePathInside(relativePath); +} + +function matchesCliDiscoveryGlob( + absolutePath: string, + scanRoot: string, + matcher: (relativePath: string) => boolean, +): boolean { + const relativePath = path.relative(scanRoot, absolutePath); + if (!isCliDiscoveryRelativePathInside(relativePath)) { + return false; + } + return matcher(normalizePath(relativePath)); +} + +export function filterFilesByCliDiscoveryGlobs( + files: readonly string[], + scanRoot: string, + discovery: ProjectFileDiscoveryOptions, +): string[] { + const includeMatchers = (discovery.includeGlobs ?? []) + .map(normalizeCliGlobPattern) + .filter(Boolean) + .map((globPattern) => picomatch(globPattern, { dot: true })); + const ignoreMatchers = (discovery.ignoreGlobs ?? []) + .map(normalizeCliGlobPattern) + .filter(Boolean) + .map((globPattern) => picomatch(globPattern, { dot: true })); + + if (!includeMatchers.length && !ignoreMatchers.length) { + return [...files]; + } + + return files.filter((filePath) => { + if ( + includeMatchers.length && + !includeMatchers.some((matcher) => matchesCliDiscoveryGlob(filePath, scanRoot, matcher)) + ) { + return false; + } + return !ignoreMatchers.some((matcher) => matchesCliDiscoveryGlob(filePath, scanRoot, matcher)); + }); +} + +export type CliRuntime = { + stdout: (chunk: string) => void; + stderr: (chunk: string) => void; + exit: (code: number) => never; + cwd: () => string; +}; + +type CliContext = { + runtime: CliRuntime; + stderrFilePath: string | undefined; +}; + +function createDefaultCliRuntime(): CliRuntime { + return { + stdout: (chunk) => process.stdout.write(chunk), + stderr: (chunk) => process.stderr.write(chunk), + exit: (code) => process.exit(code), + cwd: () => process.cwd(), + }; +} + +const defaultCliContext: CliContext = { + runtime: createDefaultCliRuntime(), + stderrFilePath: undefined, +}; +const cliContextStorage = new AsyncLocalStorage(); + +function createCliContext(runtime: Partial = {}): CliContext { + return { + runtime: { ...createDefaultCliRuntime(), ...runtime }, + stderrFilePath: undefined, + }; +} + +function getCliContext(): CliContext { + return cliContextStorage.getStore() ?? defaultCliContext; +} + +export async function runWithCliRuntime(runtime: Partial, callback: () => Promise): Promise { + const context = createCliContext(runtime); + return await cliContextStorage.run(context, callback); +} + +export function getCwd(): string { + return getCliContext().runtime.cwd(); +} + +export function exitCli(code: number): never { + return getCliContext().runtime.exit(code); +} + +function writeStderrChunk(message: string): void { + getCliContext().runtime.stderr(message); +} + +export function setCliStderrFilePath(filePath: string | undefined): void { + getCliContext().stderrFilePath = filePath; +} + +export function writeStdoutLine(message: string): void { + getCliContext().runtime.stdout(`${message}\n`); +} + +export function writeJSONLine(value: unknown): void { + writeStdoutLine(toJSON(value)); +} + +export function writeStderrLine(message: string): void { + const context = getCliContext(); + context.runtime.stderr(`${message}\n`); + try { + if (context.stderrFilePath) { + fs.appendFileSync(context.stderrFilePath, `${message}\n`, { + encoding: "utf8", + }); + } + } catch { + // Swallow file logging errors to avoid masking primary error output. + } +} + +export function writeError(error: unknown): void { + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + return; + } + writeStderrLine(String(error)); +} + +function formatNativeBackendStatus(report: BuildReport | undefined): string | undefined { + const native = report?.backend?.native; + if (!native) return undefined; + if (native.filesUsed > 0) { + if (native.filesFellBack > 0) { + return `Backend: native tree-sitter used for ${native.filesUsed} file(s); fallback for ${native.filesFellBack} file(s)`; + } + return `Backend: native tree-sitter used for ${native.filesUsed} file(s)`; + } + const fallbackTotal = native.filesFellBack; + if (native.available) { + if (fallbackTotal > 0) { + return `Backend: JS tree-sitter fallback for ${fallbackTotal} file(s)`; + } + return "Backend: native tree-sitter available"; + } + const reason = native.loadError ? ` (${native.loadError})` : ""; + return `Backend: JS tree-sitter fallback; native addon unavailable${reason}`; +} + +function formatNativeBackendFallbackSummary(report: BuildReport | undefined): string | undefined { + const native = report?.backend?.native; + if (!native || native.filesFellBack === 0) return undefined; + const parts = Object.entries(native.byLanguage) + .filter(([, entry]) => entry.filesFellBack > 0) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([languageId, entry]) => { + const reasonSummary = Object.entries(entry.fallbackReasons) + .filter(([, count]) => count > 0) + .map(([reason, count]) => `${reason}=${count}`) + .join(","); + return reasonSummary.length ? `${languageId}(${reasonSummary})` : `${languageId}(${entry.filesFellBack})`; + }); + if (!parts.length) return undefined; + return `Native fallback summary: ${parts.join(", ")}`; +} + +function formatParserBackendSummary(report: BuildReport | undefined): string | undefined { + const parser = report?.backend?.parser; + if (!parser || parser.total === 0) return undefined; + const parts = Object.entries(parser.byLanguage) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([languageId, count]) => `${languageId}(${count})`); + if (!parts.length) { + return `Parser backend degradation: ${parser.total} file(s)`; + } + return `Parser backend degradation: ${parser.total} file(s) [${parts.join(", ")}]`; +} + +export function maybeWriteNativeBackendStatus(report: BuildReport | undefined, showProgress: boolean): void { + if (!showProgress) return; + const message = formatNativeBackendStatus(report); + if (message) writeStderrLine(message); + const summary = formatNativeBackendFallbackSummary(report); + if (summary) writeStderrLine(summary); + const parserSummary = formatParserBackendSummary(report); + if (parserSummary) writeStderrLine(parserSummary); +} + +export function createCliProgressHandler( + showProgress: boolean, +): ((update: { current: number; total: number }) => void) | undefined { + if (!showProgress) return undefined; + let lastProgressUpdate = 0; + return (update) => { + const now = Date.now(); + const isComplete = update.current === update.total; + const shouldUpdate = isComplete || now - lastProgressUpdate > 100; + + if (shouldUpdate) { + if (process.stderr.isTTY) { + writeStderrChunk(`\r[Progress] ${update.current}/${update.total} files processed...`); + if (isComplete) { + writeStderrChunk("\n"); + } + } else if (update.current === 1 || isComplete || update.current % 100 === 0) { + writeStderrChunk(`[Progress] ${update.current}/${update.total} files processed.\n`); + } + lastProgressUpdate = now; + } + }; +} + +type CommandTimingReport = { + totalMs?: number; + resolveFilesMs?: number; + commandMs?: number; +}; + +export type CommandReport = { + command: string; + timings: CommandTimingReport; + index?: BuildReport; + review?: ReviewBuildReport; +}; + +export type ParsedCliArgs = { + positionals: string[]; + flags: Set; + options: Map; +}; + +export function parseCliArgs(command: string, tokens: string[]): ParsedCliArgs { + const positionals: string[] = []; + const flags = new Set(); + const options = new Map(); + + const pushOpt = (key: string, value: string) => { + const existing = options.get(key); + if (existing) existing.push(value); + else options.set(key, [value]); + }; + + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + if (t === "--") { + positionals.push(...tokens.slice(i + 1)); + break; + } + + if (t.startsWith("--")) { + const eq = t.indexOf("="); + if (eq !== -1) { + const key = t.slice(0, eq); + const value = t.slice(eq + 1); + pushOpt(key, value); + continue; + } + const key = t; + if (isCliValueOption(command, key, positionals)) { + const next = tokens[i + 1]; + if (next === undefined) throw new Error(`Missing value for ${key} option`); + pushOpt(key, next); + i++; + } else { + flags.add(key); + } + continue; + } + + if (t.startsWith("-") && t.length > 1) { + // Support a minimal set of short options. Everything else is treated as a boolean flag. + if (t === "-o") { + const next = tokens[i + 1]; + if (!next || next.startsWith("-")) throw new Error("Missing value for -o/--output"); + pushOpt("--output", next); + i++; + continue; + } + flags.add(t); + continue; + } + + positionals.push(t); + } + + return { positionals, flags, options }; +} + +export async function writeCommandReport(report: CommandReport, reportFile: string | undefined): Promise { + const payload = JSON.stringify(report, null, 2); + if (reportFile) { + const resolved = normalizePath(resolveFilePathFromRoot(getCwd(), reportFile)); + await fsp.writeFile(resolved, `${payload}\n`, "utf8"); + } else { + writeStderrLine(payload); + } +} diff --git a/src/cli/graph.ts b/src/cli/graph.ts new file mode 100644 index 00000000..5c752a2a --- /dev/null +++ b/src/cli/graph.ts @@ -0,0 +1,486 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { + buildSymbolGraph, + buildSymbolGraphDetailed, + collectGraph, + graphToDOT, + graphToDOTSymbols, + graphToDOTSymbolsWithFiles, + graphToMermaid, + graphToMermaidSymbols, + graphToMermaidSymbolsWithFiles, + type SymbolGraph, +} from "../graphs.js"; +import { buildProjectIndexFromFiles, buildProjectIndexIncremental, type BuildReport } from "../indexer.js"; +import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; +import { updateGraphSqlite, writeGraphSqlite } from "../sqlite.js"; +import { buildSqlArtifactGraphFromFiles } from "../sql/index.js"; +import type { Graph } from "../types.js"; +import { normalizePath, resolveFilePathFromRoot, type ProjectFileDiscoveryOptions } from "../util.js"; +import { + parseCacheModeOption, + parseNonNegativeIntegerOption, + parseOptionalNonNegativeIntegerOption, +} from "./options.js"; + +type CompactEdgeTo = { type: "file"; path: number } | { type: "external"; name: string }; +type CompactFileEdge = { + from: number; + to: CompactEdgeTo; + raw: string; + typeOnly?: boolean; +}; +type CompactSymbolEdge = { from: number; to: number; label?: string }; + +type CommandTimingReport = { + totalMs?: number; + resolveFilesMs?: number; + commandMs?: number; +}; + +type GraphCommandReport = { + command: string; + timings: CommandTimingReport; + index?: BuildReport; +}; + +export type GraphCommandContext = { + projectRootFs: string; + discoveryOptions: ProjectFileDiscoveryOptions; + nativeMode: NativeRuntimeMode; + workerOpts: { useNativeWorkers: true } | Record; + progressHandler: ((update: { current: number; total: number }) => void) | undefined; + graphFlags: { + fast: boolean; + resolveNodeModules: boolean; + dynamicImportHeuristics: boolean; + resolutionHints: string[]; + }; + gitBase: string | undefined; + gitHead: string | undefined; + changedSince: string | undefined; + reportEnabled: boolean; + reportFile: string | undefined; + showProgress: boolean; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + cwd: () => string; + resolveFiles: () => Promise; + resolveChangedFilesWithDeletes: () => Promise<{ + existingFiles: string[]; + deletedFiles: string[]; + } | null>; + writeStdoutLine: (message: string) => void; + setStderrFilePath: (filePath: string | undefined) => void; + writeCommandReport: (report: GraphCommandReport, reportFile: string | undefined) => Promise; + maybeWriteNativeBackendStatus: (report: BuildReport | undefined, showProgress: boolean) => void; +}; + +function toJSON(obj: unknown): string { + return JSON.stringify(obj, null, 2); +} + +function compactGraphWithSymbols(fgraph: Graph, sgraph: SymbolGraph, stable = false) { + const files = [...fgraph.nodes]; + if (stable) files.sort(); + const fileIndex = new Map(); + for (let i = 0; i < files.length; i++) fileIndex.set(files[i]!, i); + + const fileEdges: CompactFileEdge[] = fgraph.edges.map((e) => ({ + from: fileIndex.get(e.from)!, + to: + e.to?.type === "file" + ? { type: "file" as const, path: fileIndex.get(e.to.path)! } + : { type: "external" as const, name: e.to.name }, + raw: e.raw, + ...(e.typeOnly !== undefined ? { typeOnly: e.typeOnly } : {}), + })); + if (stable) { + const toKey = (to: CompactEdgeTo) => (to?.type === "file" ? `file:${to.path}` : `ext:${to?.name ?? ""}`); + fileEdges.sort((a, b) => { + const byFrom = a.from - b.from; + if (byFrom) return byFrom; + const ak = toKey(a.to); + const bk = toKey(b.to); + if (ak !== bk) return ak < bk ? -1 : 1; + const ar = String(a.raw ?? ""); + const br = String(b.raw ?? ""); + if (ar !== br) return ar < br ? -1 : 1; + return Number(!!a.typeOnly) - Number(!!b.typeOnly); + }); + } + + const symbolIds = [...sgraph.nodes.keys()]; + if (stable) symbolIds.sort(); + const symbolIndex = new Map(); + for (let i = 0; i < symbolIds.length; i++) symbolIndex.set(symbolIds[i]!, i); + + const symbols = symbolIds.map((id) => { + const n = sgraph.nodes.get(id)!; + return { + id: symbolIndex.get(id)!, + file: fileIndex.get(n.file)!, + name: n.name, + kind: n.kind, + }; + }); + + const symbolEdges: CompactSymbolEdge[] = sgraph.edges.map((e) => ({ + from: symbolIndex.get(e.from)!, + to: symbolIndex.get(e.to)!, + ...(e.label ? { label: e.label } : {}), + })); + if (stable) { + symbolEdges.sort((a, b) => { + const byFrom = a.from - b.from; + if (byFrom) return byFrom; + const byTo = a.to - b.to; + if (byTo) return byTo; + const al = String(a.label ?? ""); + const bl = String(b.label ?? ""); + if (al !== bl) return al < bl ? -1 : 1; + return 0; + }); + } + + return { + files, + fileEdges, + symbols, + symbolEdges, + symbolIdIndex: symbolIds, + }; +} + +function compactSymbolsOnly(allFiles: string[], sgraph: SymbolGraph, stable = false) { + const files = [...allFiles]; + if (stable) files.sort(); + const fileIndex = new Map(); + for (let i = 0; i < files.length; i++) fileIndex.set(files[i]!, i); + + const symbolIds = [...sgraph.nodes.keys()]; + if (stable) symbolIds.sort(); + const symbolIndex = new Map(); + for (let i = 0; i < symbolIds.length; i++) symbolIndex.set(symbolIds[i]!, i); + + const symbols = symbolIds.map((id) => { + const n = sgraph.nodes.get(id)!; + return { + id: symbolIndex.get(id)!, + file: fileIndex.get(n.file)!, + name: n.name, + kind: n.kind, + }; + }); + + const symbolEdges: CompactSymbolEdge[] = sgraph.edges.map((e) => ({ + from: symbolIndex.get(e.from)!, + to: symbolIndex.get(e.to)!, + ...(e.label ? { label: e.label } : {}), + })); + if (stable) { + symbolEdges.sort((a, b) => { + const byFrom = a.from - b.from; + if (byFrom) return byFrom; + const byTo = a.to - b.to; + if (byTo) return byTo; + const al = String(a.label ?? ""); + const bl = String(b.label ?? ""); + if (al !== bl) return al < bl ? -1 : 1; + return 0; + }); + } + + return { + files, + symbols, + symbolEdges, + symbolIdIndex: symbolIds, + }; +} + +function stabilizeGraph(graph: Graph): Graph { + const nodes = [...graph.nodes].slice().sort(); + const edges = [...graph.edges].slice().sort((a, b) => { + const af = String(a.from); + const bf = String(b.from); + if (af !== bf) return af < bf ? -1 : 1; + const at = a.to.type === "file" ? `file:${a.to.path}` : `ext:${a.to.name ?? ""}`; + const bt = b.to.type === "file" ? `file:${b.to.path}` : `ext:${b.to.name ?? ""}`; + if (at !== bt) return at < bt ? -1 : 1; + const ar = String(a.raw ?? ""); + const br = String(b.raw ?? ""); + if (ar !== br) return ar < br ? -1 : 1; + return Number(!!a.typeOnly) - Number(!!b.typeOnly); + }); + return { nodes: new Set(nodes), edges }; +} + +function stabilizeSymbolGraph(graph: SymbolGraph): SymbolGraph { + const nodeEntries = [...graph.nodes.entries()].slice().sort((a, b) => { + const ak = a[0]; + const bk = b[0]; + if (ak !== bk) return ak < bk ? -1 : 1; + return 0; + }); + const edges = [...graph.edges].slice().sort((a, b) => { + const af = String(a.from); + const bf = String(b.from); + if (af !== bf) return af < bf ? -1 : 1; + const at = String(a.to); + const bt = String(b.to); + if (at !== bt) return at < bt ? -1 : 1; + const al = String(a.label ?? ""); + const bl = String(b.label ?? ""); + if (al !== bl) return al < bl ? -1 : 1; + return 0; + }); + return { nodes: new Map(nodeEntries), edges }; +} + +export async function handleGraphCommand(context: GraphCommandContext): Promise { + const commandReport: GraphCommandReport | undefined = context.reportEnabled + ? { command: "graph", timings: {} } + : undefined; + const commandStart = performance.now(); + const resolveStart = performance.now(); + const files = await context.resolveFiles(); + if (commandReport) { + commandReport.timings.resolveFilesMs = Math.round(performance.now() - resolveStart); + } + const hasExplicitSymbolFlag = + context.hasFlag("--symbols") || context.hasFlag("--symbols-only") || context.hasFlag("--symbols-detailed"); + const hasExplicitFormatFlag = context.hasFlag("--mermaid") || context.hasFlag("--dot") || context.hasFlag("--json"); + const outputArg = context.getOpt("--output"); + const sqliteArg = context.getOpt("--sqlite"); + const stderrArg = context.getOpt("--stderr-file"); + const stdoutMode = context.hasFlag("--stdout"); + const defaultGraphMode = !hasExplicitSymbolFlag && !hasExplicitFormatFlag; + + const wantSymbols = hasExplicitSymbolFlag; + const detailedSymbols = context.hasFlag("--symbols-detailed"); + const threads = parseNonNegativeIntegerOption(context.getOpt("--threads"), "--threads", 0); + const cache = parseCacheModeOption(context.getOpt("--cache")); + const cacheStrict = context.hasFlag("--cache-strict"); + const stable = context.hasFlag("--stable"); + let format: "mermaid" | "dot" | "json" = "json"; + if (context.hasFlag("--mermaid")) { + format = "mermaid"; + } else if (context.hasFlag("--dot")) { + format = "dot"; + } + const fast = context.graphFlags.fast; + const resolveNodeModules = context.graphFlags.resolveNodeModules; + const dynamicImportHeuristics = context.graphFlags.dynamicImportHeuristics; + const resolutionHints = context.graphFlags.resolutionHints; + const compact = defaultGraphMode || context.hasFlag("--compact-json"); + const includeSqlArtifacts = context.hasFlag("--sql-artifacts"); + let outputFile: string | undefined; + if (outputArg) { + outputFile = normalizePath(resolveFilePathFromRoot(context.cwd(), outputArg)); + } else if (defaultGraphMode && !stdoutMode) { + outputFile = path.resolve(context.cwd(), "codegraph.json").replace(/\\/g, "/"); + } + const sqliteFile = sqliteArg ? normalizePath(resolveFilePathFromRoot(context.cwd(), sqliteArg)) : undefined; + if (stderrArg) { + context.setStderrFilePath(normalizePath(resolveFilePathFromRoot(context.cwd(), stderrArg))); + } else if (defaultGraphMode) { + context.setStderrFilePath(path.resolve(context.cwd(), "codegraph.err").replace(/\\/g, "/")); + } else { + context.setStderrFilePath(undefined); + } + + const finalizeReport = async () => { + if (!commandReport) return; + commandReport.timings.commandMs = Math.round(performance.now() - commandStart); + commandReport.timings.totalMs = commandReport.timings.commandMs; + await context.writeCommandReport(commandReport, context.reportFile); + }; + + const writeOut = async (text: string) => { + if (outputFile) { + await fsp.writeFile(outputFile, `${text}\n`, "utf8"); + } else { + context.writeStdoutLine(text); + } + }; + const indexReport: BuildReport | undefined = + context.reportEnabled || context.showProgress ? { timings: {} } : undefined; + if (commandReport && indexReport) { + commandReport.index = indexReport; + } + if (sqliteFile) { + const changedSet = await context.resolveChangedFilesWithDeletes(); + const graphOptions = { + fast, + resolveNodeModules, + dynamicImportHeuristics, + ...(resolutionHints.length ? { resolutionHints } : {}), + }; + const sqliteCacheMode = cache ?? (changedSet ? "disk" : undefined); + const index = changedSet + ? await buildProjectIndexIncremental(context.projectRootFs, { + onProgress: context.progressHandler, + threads, + discovery: context.discoveryOptions, + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...context.workerOpts, + ...(sqliteCacheMode !== undefined ? { cache: sqliteCacheMode } : {}), + cacheStrict, + files: changedSet.existingFiles, + ...(context.gitBase ? { gitBase: context.gitBase } : {}), + ...(context.gitHead ? { gitHead: context.gitHead } : {}), + ...(context.changedSince ? { changedSince: context.changedSince } : {}), + graph: graphOptions, + ...(indexReport ? { report: indexReport } : {}), + }) + : await buildProjectIndexFromFiles(context.projectRootFs, files, { + onProgress: context.progressHandler, + threads, + discovery: context.discoveryOptions, + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...context.workerOpts, + ...(sqliteCacheMode !== undefined ? { cache: sqliteCacheMode } : {}), + cacheStrict, + graph: graphOptions, + ...(indexReport ? { report: indexReport } : {}), + }); + context.maybeWriteNativeBackendStatus(indexReport, context.showProgress); + + const detailedSymbols = context.hasFlag("--symbols-detailed"); + const scope = context.getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined; + const maxEdgesRaw = context.getOpt("--symbols-detailed-max-edges"); + const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges"); + const membersOnly = context.hasFlag("--symbols-detailed-members-only"); + const sgraph = detailedSymbols + ? await buildSymbolGraphDetailed(index, { + ...(scope !== undefined ? { scope } : {}), + ...(typeof maxEdges === "number" ? { maxEdges } : {}), + membersOnly, + }) + : await buildSymbolGraph(index); + + const sqliteDbExists = fs.existsSync(sqliteFile); + if (changedSet && sqliteDbExists) { + await updateGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: sqliteFile, + changedFiles: changedSet.existingFiles, + deletedFiles: changedSet.deletedFiles, + fullGraphSync: true, + }); + } else { + await writeGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: sqliteFile, + }); + } + await finalizeReport(); + return; + } + if (wantSymbols) { + const index = await buildProjectIndexFromFiles(context.projectRootFs, files, { + onProgress: context.progressHandler, + threads, + discovery: context.discoveryOptions, + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...context.workerOpts, + ...(cache !== undefined ? { cache } : {}), + cacheStrict, + graph: { + fast, + resolveNodeModules, + dynamicImportHeuristics, + ...(resolutionHints.length ? { resolutionHints } : {}), + }, + ...(indexReport ? { report: indexReport } : {}), + }); + context.maybeWriteNativeBackendStatus(indexReport, context.showProgress); + let sgraph: SymbolGraph; + if (detailedSymbols) { + const scope = context.getOpt("--symbols-detailed-scope") as "all" | "imported" | undefined; + const maxEdgesRaw = context.getOpt("--symbols-detailed-max-edges"); + const maxEdges = parseOptionalNonNegativeIntegerOption(maxEdgesRaw, "--symbols-detailed-max-edges"); + const membersOnly = context.hasFlag("--symbols-detailed-members-only"); + sgraph = await buildSymbolGraphDetailed(index, { + ...(scope !== undefined ? { scope } : {}), + ...(typeof maxEdges === "number" ? { maxEdges } : {}), + membersOnly, + }); + } else { + sgraph = await buildSymbolGraph(index); + } + const sgraphOut = stable ? stabilizeSymbolGraph(sgraph) : sgraph; + if (context.hasFlag("--symbols-only")) { + if (format === "mermaid") { + await writeOut(graphToMermaidSymbols(sgraphOut, context.projectRootFs)); + } else if (format === "dot") { + await writeOut(graphToDOTSymbols(sgraphOut, context.projectRootFs)); + } else if (compact) { + const allFiles = [...index.graph.nodes]; + await writeOut(toJSON(compactSymbolsOnly(allFiles, sgraphOut, stable))); + } else { + await writeOut( + toJSON({ + nodes: [...sgraphOut.nodes.values()], + edges: sgraphOut.edges, + }), + ); + } + await finalizeReport(); + return; + } + const fgraph = index.graph; + const fgraphOut = stable ? stabilizeGraph(fgraph) : fgraph; + if (format === "mermaid") { + await writeOut(graphToMermaidSymbolsWithFiles(sgraphOut, fgraphOut, context.projectRootFs)); + } else if (format === "dot") { + await writeOut(graphToDOTSymbolsWithFiles(sgraphOut, fgraphOut, context.projectRootFs)); + } else if (compact) { + await writeOut(toJSON(compactGraphWithSymbols(fgraphOut, sgraphOut, stable))); + } else { + await writeOut( + toJSON({ + files: [...fgraphOut.nodes], + fileEdges: fgraphOut.edges, + symbols: [...sgraphOut.nodes.values()], + symbolEdges: sgraphOut.edges, + }), + ); + } + await finalizeReport(); + return; + } + const graph = await collectGraph(context.projectRootFs, files, { + fast, + threads, + resolveNodeModules, + dynamicImportHeuristics, + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + ...(resolutionHints.length ? { resolutionHints } : {}), + ...(indexReport ? { report: indexReport } : {}), + }); + context.maybeWriteNativeBackendStatus(indexReport, context.showProgress); + const graphOut = stable ? stabilizeGraph(graph) : graph; + if (format === "mermaid") { + await writeOut(graphToMermaid(graphOut)); + } else if (format === "dot") { + await writeOut(graphToDOT(graphOut)); + } else { + const sqlFiles = includeSqlArtifacts ? files.filter((file) => path.extname(file).toLowerCase() === ".sql") : []; + const sqlArtifacts = sqlFiles.length ? await buildSqlArtifactGraphFromFiles(sqlFiles) : undefined; + await writeOut( + toJSON({ + nodes: [...graphOut.nodes], + edges: graphOut.edges, + ...(sqlArtifacts ? { sqlArtifacts } : {}), + }), + ); + } + await finalizeReport(); +} From d7d5ba9fd3ef23cbf450863a0e497f51f2ddf887 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 17:36:39 -0400 Subject: [PATCH 07/25] Split review report phases --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/review.ts | 1299 +------------------------------------- src/review/candidates.ts | 82 +++ src/review/changes.ts | 105 +++ src/review/deleted.ts | 443 +++++++++++++ src/review/report.ts | 129 ++++ src/review/risk.ts | 125 ++++ src/review/summaries.ts | 465 ++++++++++++++ 8 files changed, 1364 insertions(+), 1286 deletions(-) create mode 100644 src/review/candidates.ts create mode 100644 src/review/changes.ts create mode 100644 src/review/deleted.ts create mode 100644 src/review/report.ts create mode 100644 src/review/risk.ts create mode 100644 src/review/summaries.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 1370d4e1..ffd19db9 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -63,7 +63,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Keep `src/cli.ts` as a thin dispatcher. - Verify with `tests/cli-regressions.test.ts`, `tests/cli-command-modules.test.ts`, and focused command tests. -- [ ] Split `src/review.ts` into phase modules. +- [x] Split `src/review.ts` into phase modules. - Current shape: review options, deleted-file reconstruction, graph delta, SQL context, candidate tests, symbol summaries, risk scoring, and final assembly live in one file. - Target split: - `src/review/changes.ts` for changed file and diff collection. diff --git a/src/review.ts b/src/review.ts index 01eb9b43..6d495a89 100644 --- a/src/review.ts +++ b/src/review.ts @@ -1,72 +1,27 @@ -import fs from "node:fs"; -import path from "node:path"; -import fsp from "node:fs/promises"; -import { execFile } from "node:child_process"; import { performance } from "node:perf_hooks"; -import { promisify } from "node:util"; -import type { Edge, FileId, Range } from "./types.js"; +import type { Edge, FileId } from "./types.js"; import { buildProjectIndexIncremental, type BuildReport, - collectImportsForFile, - collectLocalsAndExportsFromSource, - type ExportEntry, - findReferences, - type ImportBinding, type IncrementalBuildOptions, - type ModuleIndex, type ProjectIndex, - type SymbolDef, - symbolId, } from "./indexer.js"; -import { isSymbolHandleExported } from "./indexer/declarations.js"; import type { GraphBuildOptions } from "./graphs/types.js"; -import { locateChangedSymbolsWithLines, mapChangedLinesToSymbols } from "./impact/map.js"; -import { parseUnifiedDiff } from "./impact/parse.js"; -import { createImpactIgnoreMatcher } from "./impact/path.js"; import type { FileChange, Hunk } from "./impact/types.js"; -import { listCandidateTestFiles, type CandidateTestFile } from "./impact/context.js"; -import { compileTestPatterns, createIndexTestFileMatcher } from "./impact/testPatterns.js"; +import type { CandidateTestFile } from "./impact/context.js"; +import { fileExists, discoverProjectFiles, type ProjectFileInfo } from "./util.js"; +import type { SqlReviewContext } from "./sql/review.js"; +import { collectReviewCandidateTests } from "./review/candidates.js"; +import { collectReviewChanges } from "./review/changes.js"; +import { buildDeletedFileSnapshots, type DeletedFileSnapshot } from "./review/deleted.js"; import { - assertFilePathWithinRoot, - normalizePath, - listChangedFiles, - fileExists, - getUnifiedDiff, - discoverProjectFiles, - loadNearestTsconfigFor, - loadWorkspaceConfig, - listResolutionCandidates, - listWorkspacePackageResolutionCandidates, - toProjectRelativePath, - type ProjectFileInfo, - type WorkspaceConfig, -} from "./util.js"; -import { supportForFile } from "./languages.js"; -import { collectSqlReviewContext, type SqlReviewContext } from "./sql/review.js"; - -const execFileAsync = promisify(execFile); - -type ReviewFileSummary = { - file: string; - status: "updated" | "deleted" | "missing"; - symbols: ReviewSymbolSummary[]; -}; - -type ReviewSymbolCallsite = { - file: string; - range: Range; -}; - -type ReviewSymbolSummary = { - name: string; - kind: string; - handle: string; - exported: boolean; - definitionSnippet?: string; - diffSnippets?: string[]; - callsites?: ReviewSymbolCallsite[]; -}; + assembleReviewReport, + collectReviewGraphDelta, + collectReviewSqlContext, + REVIEW_SCHEMA_VERSION, +} from "./review/report.js"; +import { buildReviewTasks, computeRiskSummary } from "./review/risk.js"; +import { summarizeChangedFiles, type ReviewFileSummary } from "./review/summaries.js"; /** * Structured review bundle for downstream review agents. @@ -161,13 +116,6 @@ type ReviewPreset = { graph: { fast: boolean }; }; -type DeletedFileSnapshot = { - source: string; - module: ModuleIndex; -}; - -type ReviewableExportEntry = Exclude; - const REVIEW_PRESETS: Record = { minimal: { includeSymbolDetails: false, @@ -189,8 +137,6 @@ const REVIEW_PRESETS: Record = { }, }; -const REVIEW_SCHEMA_VERSION = 2; - function mergeGraphOptions( base: IncrementalBuildOptions["graph"] | undefined, override: IncrementalBuildOptions["graph"] | undefined, @@ -213,820 +159,10 @@ function applyReviewPresetOptions(opts: ReviewOptions): ReviewOptions { }; } -function relativePath(root: string, file: string): string { - return toProjectRelativePath(root, file) ?? normalizePath(file); -} - function comparePaths(left: string, right: string): number { return left.localeCompare(right); } -function compareEdges(left: Edge, right: Edge): number { - const fromCompare = comparePaths(left.from, right.from); - if (fromCompare !== 0) return fromCompare; - if (left.to.type !== right.to.type) { - return left.to.type === "file" ? -1 : 1; - } - const leftTarget = left.to.type === "file" ? left.to.path : left.to.name; - const rightTarget = right.to.type === "file" ? right.to.path : right.to.name; - const toCompare = comparePaths(leftTarget, rightTarget); - if (toCompare !== 0) return toCompare; - const rawCompare = left.raw.localeCompare(right.raw); - if (rawCompare !== 0) return rawCompare; - const leftTypeOnly = left.typeOnly ? 1 : 0; - const rightTypeOnly = right.typeOnly ? 1 : 0; - return leftTypeOnly - rightTypeOnly; -} - -function confidenceRank(confidence: CandidateTestFile["confidence"]): number { - if (confidence === "high") return 3; - if (confidence === "medium") return 2; - return 1; -} - -function mergeCandidateTestEntries( - baseCandidates: CandidateTestFile[], - additionalCandidates: CandidateTestFile[], -): CandidateTestFile[] { - const merged = new Map(); - const upsert = (candidate: CandidateTestFile) => { - const existing = merged.get(candidate.file); - if (!existing) { - merged.set(candidate.file, candidate); - return; - } - if (confidenceRank(candidate.confidence) > confidenceRank(existing.confidence)) { - merged.set(candidate.file, candidate); - } - }; - for (const candidate of baseCandidates) upsert(candidate); - for (const candidate of additionalCandidates) upsert(candidate); - return Array.from(merged.values()); -} - -function normalizeSpecifierBase(fromFile: string, spec: string): string { - return normalizePath(path.resolve(path.dirname(fromFile), spec)); -} - -function buildDeletedImportCandidates(fromFile: string, spec: string, targetFile: string): Set { - const normalizedSpec = spec.replace(/\\/g, "/"); - const basePath = normalizeSpecifierBase(fromFile, normalizedSpec); - const resolutionExtensions = deletedImportResolutionExtensions(targetFile); - const candidates = listResolutionCandidates(basePath, resolutionExtensions).map((candidate) => - normalizePath(candidate), - ); - return new Set(candidates); -} - -function matchesDeletedImportTarget( - fromFile: string, - spec: string, - resolved: string | undefined, - deletedFile: string, -): boolean { - if (resolved && normalizePath(resolved) === deletedFile) { - return true; - } - if (!spec.startsWith(".")) { - return false; - } - return buildDeletedImportCandidates(fromFile, spec, deletedFile).has(deletedFile); -} - -function getImportResolvedPath(entry: Pick): string | undefined { - return typeof entry.resolved === "string" ? entry.resolved : undefined; -} - -function buildDeletedAliasCandidates(candidate: string, targetFile: string): Set { - const normalizedCandidate = normalizePath(candidate); - const resolutionExtensions = deletedImportResolutionExtensions(targetFile); - const resolutionCandidates = listResolutionCandidates(normalizedCandidate, resolutionExtensions); - const resolvedCandidates = resolutionCandidates.map((resolvedCandidate) => normalizePath(resolvedCandidate)); - return new Set(resolvedCandidates); -} - -function deletedImportResolutionExtensions(targetFile: string): string[] { - const targetExt = path.extname(targetFile); - return targetExt ? [targetExt] : []; -} - -async function resolveDeletedAliasImportTarget( - projectRoot: string | undefined, - workspaceConfig: WorkspaceConfig | undefined, - fromFile: string, - spec: string, - deletedFile: string, -): Promise { - if (spec.startsWith(".") || spec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(spec)) { - return undefined; - } - const deletedTarget = normalizePath(deletedFile); - const resolutionExtensions = deletedImportResolutionExtensions(deletedFile); - const { matchPath } = await loadNearestTsconfigFor(fromFile); - if (matchPath) { - const matched = matchPath( - spec, - undefined, - (candidate) => buildDeletedAliasCandidates(candidate, deletedFile).has(deletedTarget), - resolutionExtensions, - ); - if (matched) { - const resolvedMatch = Array.from(buildDeletedAliasCandidates(matched, deletedFile)).find( - (candidate) => candidate === deletedTarget, - ); - if (resolvedMatch) { - return resolvedMatch; - } - } - } - - if (!projectRoot) { - return undefined; - } - - return listWorkspacePackageResolutionCandidates(spec, workspaceConfig, resolutionExtensions) - .map((candidate) => normalizePath(candidate)) - .find((candidate) => candidate === deletedTarget); -} - -async function listDirectDeletedFileTestImporters( - index: ProjectIndex, - deletedFiles: readonly string[], - testPatterns: string[] = [], - projectRoot?: string, -): Promise { - if (!deletedFiles.length) return []; - - const deletedFileSet = new Set(deletedFiles.map((file) => normalizePath(file))); - const compiledPatterns = compileTestPatterns(testPatterns); - const isIndexTestFile = createIndexTestFileMatcher(index, compiledPatterns, projectRoot); - const candidates = new Map(); - const importsByFile = new Map>(); - const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; - - for (const edge of index.graph.edges) { - let imports = importsByFile.get(edge.from); - if (!imports) { - imports = []; - importsByFile.set(edge.from, imports); - } - imports.push({ - spec: edge.raw, - ...(edge.to.type === "file" ? { resolved: edge.to.path } : {}), - }); - } - - for (const mod of index.byFile.values()) { - if (!isIndexTestFile(mod.file)) continue; - const uniqueImports = new Map(); - for (const entry of importsByFile.get(mod.file) ?? []) { - uniqueImports.set(`${entry.spec}::${entry.resolved ?? ""}`, entry); - } - for (const imp of mod.imports) { - const resolved = getImportResolvedPath(imp); - uniqueImports.set(`${imp.from}::${resolved ?? ""}`, { - spec: imp.from, - ...(resolved ? { resolved } : {}), - }); - } - for (const entry of uniqueImports.values()) { - for (const deletedFile of deletedFileSet) { - const resolvedImportPath = entry.resolved ? normalizePath(entry.resolved) : undefined; - const resolvedAliasTarget = - resolvedImportPath === deletedFile - ? resolvedImportPath - : await resolveDeletedAliasImportTarget(projectRoot, workspaceConfig, mod.file, entry.spec, deletedFile); - if (!matchesDeletedImportTarget(mod.file, entry.spec, resolvedAliasTarget, deletedFile)) { - continue; - } - candidates.set(mod.file, { - file: mod.file, - confidence: "high", - reason: "importsChanged", - }); - } - } - } - - return Array.from(candidates.values()); -} -async function readGitFileAtRevision(projectRoot: string, revision: string, file: string): Promise { - const relativeFile = normalizePath(path.relative(projectRoot, file)); - if (!relativeFile || relativeFile.startsWith("..")) return null; - try { - const { stdout } = await execFileAsync("git", ["show", `${revision}:${relativeFile}`], { - cwd: projectRoot, - encoding: "utf8", - maxBuffer: 16 * 1024 * 1024, - }); - return stdout; - } catch { - return null; - } -} - -async function buildDeletedFileSnapshots( - projectRoot: string, - deletedFiles: readonly string[], - opts: { - revision?: string; - diffChangesByFile?: ReadonlyMap; - graphOptions?: GraphBuildOptions; - }, -): Promise> { - const snapshots = new Map(); - if (!deletedFiles.length) return snapshots; - - for (const file of deletedFiles) { - const support = supportForFile(file); - if (!support) continue; - const source = - (opts.revision ? await readGitFileAtRevision(projectRoot, opts.revision, file) : null) ?? - reconstructDeletedSourceFromDiff(opts.diffChangesByFile?.get(file)); - if (source === null) continue; - const normalizedFile = normalizePath(file); - const imports = await collectImportsForFile(normalizedFile, projectRoot, { - source, - sup: support, - ...(opts.graphOptions ? { graphOptions: opts.graphOptions } : {}), - }); - const module = collectLocalsAndExportsFromSource(normalizedFile, source, support, undefined, imports); - snapshots.set(normalizedFile, { - source, - module, - }); - } - - return snapshots; -} - -function reconstructDeletedSourceFromDiff(change: FileChange | undefined): string | null { - if (!change || change.kind !== "deleted" || !change.hunks.length) { - return null; - } - const oldLines: string[] = []; - for (const hunk of change.hunks) { - let oldLine = hunk.oldStart; - for (const line of hunk.lines) { - const prefix = line[0]; - if (prefix === "+") continue; - if (prefix !== " " && prefix !== "-") continue; - while (oldLines.length < oldLine - 1) { - oldLines.push(""); - } - oldLines[oldLine - 1] = line.slice(1); - oldLine += 1; - } - } - return oldLines.length ? oldLines.join("\n") : null; -} - -function sortSymbols(symbols: SymbolDef[]): SymbolDef[] { - return symbols.slice().sort((left, right) => symbolId(left).localeCompare(symbolId(right))); -} - -function computeRiskSummary(input: { - filesChanged: number; - symbolsChanged: number; - exportedChanged: number; - missingFiles: number; - parseFailures: number; -}): ReviewRiskSummary { - const signals: string[] = []; - let score = 0; - if (input.exportedChanged > 0) { - score += 60; - signals.push("exported-symbols-changed"); - } else { - score += 20; - } - if (input.symbolsChanged >= 20) { - score += 20; - signals.push("many-symbols-changed"); - } - if (input.filesChanged >= 10) { - score += 20; - signals.push("many-files-changed"); - } - if (input.missingFiles > 0) { - score += 30; - signals.push("missing-files"); - } - if (input.parseFailures > 0) { - score += 25; - signals.push("symbol-mapping-degraded"); - } - const normalizedScore = Math.min(100, score); - let level: ReviewRiskLevel = "low"; - if (normalizedScore >= 70) level = "high"; - else if (normalizedScore >= 40) level = "medium"; - return { - level, - score: normalizedScore, - signals, - }; -} - -function buildReviewTasks(input: { - filesChanged: number; - symbolsChanged: number; - exportedChanged: number; - candidateTests: number; - missingFiles: number; - parseFailures: number; -}): ReviewTask[] { - const tasks: ReviewTask[] = [ - { - id: "review-summary", - title: "Review changed symbols", - description: "Scan the changed symbols and confirm behavioral changes align with intent.", - priority: "medium", - reason: "baseline-review", - }, - ]; - - if (input.exportedChanged > 0) { - tasks.push({ - id: "api-compat", - title: "Verify API compatibility", - description: "Check exported symbols for breaking changes, migration notes, and versioning implications.", - priority: "high", - reason: "exported-symbols-changed", - }); - } - - if (input.candidateTests === 0) { - tasks.push({ - id: "tests-missing", - title: "Validate test coverage", - description: "No candidate tests were detected. Confirm existing coverage or add targeted tests.", - priority: "medium", - reason: "no-candidate-tests", - }); - } - - if (input.filesChanged >= 10 || input.symbolsChanged >= 20) { - tasks.push({ - id: "high-change-volume", - title: "Assess change scope", - description: "Large change set detected. Double-check impacted files and coordination needs.", - priority: "high", - reason: "large-change-set", - }); - } - - if (input.parseFailures > 0) { - tasks.push({ - id: "analysis-degraded", - title: "Validate degraded symbol mapping", - description: - "Some changed files could not be mapped cleanly to symbols. Review syntax errors, parser support, or fall back to file-level inspection.", - priority: "high", - reason: "symbol-mapping-degraded", - }); - } - - if (input.missingFiles > 0) { - tasks.push({ - id: "missing-input-files", - title: "Validate missing review inputs", - description: - "Some explicitly requested files were missing on disk. Confirm paths and whether the intended change was a real deletion.", - priority: "high", - reason: "missing-files", - }); - } - - return tasks; -} - -function hasDiagnostics(diagnostics: ReviewDiagnostics): boolean { - return !!(diagnostics.missingFiles.length || diagnostics.symbolMappingParseFailures.length); -} - -function isRiskRelevantSymbolMappingFile(file: string): boolean { - return supportForFile(file)?.supportsCrossModuleSymbols ?? false; -} - -function isExported(mod: { exports: ExportEntry[] }, handle: string): boolean { - return isSymbolHandleExported(mod.exports, handle); -} - -function listReviewableExports(mod: ModuleIndex): ReviewableExportEntry[] { - return mod.exports.filter((entry): entry is ReviewableExportEntry => entry.type !== "local"); -} - -function exportSummaryHandle(file: string, entry: ReviewableExportEntry): string { - const exportedAs = entry.type === "exportStar" ? "*" : entry.exportedAs; - return `${file}::export::${entry.type}::${exportedAs}::${entry.fromModule}`; -} - -function exportSummaryName(entry: ReviewableExportEntry): string { - return entry.type === "exportStar" ? "*" : entry.exportedAs; -} - -function exportSummaryKind(entry: ReviewableExportEntry): string { - return entry.type; -} - -function diffLineLooksExportLike(line: string): boolean { - const prefix = line[0]; - if (prefix !== "+" && prefix !== "-") return false; - const trimmed = line.slice(1).trimStart(); - return trimmed.startsWith("export ") || trimmed.startsWith("module.exports") || trimmed.startsWith("exports."); -} - -function shouldIncludeExportSummaries( - mod: ModuleIndex, - hunks: Hunk[] | undefined, - locals: readonly SymbolDef[], -): boolean { - if (!listReviewableExports(mod).length) return false; - if (!hunks) return true; - if (!locals.length) return true; - return hunks.some((hunk) => hunk.lines.some(diffLineLooksExportLike)); -} - -function buildExportSummaries(file: string, mod: ModuleIndex): ReviewSymbolSummary[] { - return listReviewableExports(mod).map((entry) => ({ - name: exportSummaryName(entry), - kind: exportSummaryKind(entry), - handle: exportSummaryHandle(file, entry), - exported: true, - })); -} - -function resolveReviewSpecifierTarget(fromFile: string, spec: string, knownDeletedFiles?: ReadonlySet): string { - const normalizedSpec = spec.replace(/\\/g, "/"); - const basePath = normalizeSpecifierBase(fromFile, normalizedSpec); - const candidates = listResolutionCandidates(basePath).map((candidate) => normalizePath(candidate)); - if (knownDeletedFiles) { - for (const candidate of candidates) { - if (knownDeletedFiles.has(candidate)) return candidate; - } - } - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - return candidates[0] ?? basePath; -} - -async function resolveDeletedSnapshotBareTarget( - projectRoot: string | undefined, - workspaceConfig: WorkspaceConfig | undefined, - fromFile: string, - spec: string, - knownDeletedFiles: readonly FileId[], -): Promise { - for (const deletedFile of knownDeletedFiles) { - const resolved = await resolveDeletedAliasImportTarget(projectRoot, workspaceConfig, fromFile, spec, deletedFile); - if (resolved === deletedFile) { - return deletedFile; - } - } - return undefined; -} - -async function resolveDeletedSnapshotTarget(input: { - projectRoot: string | undefined; - workspaceConfig: WorkspaceConfig | undefined; - fromFile: string; - spec: string; - knownDeletedFiles: readonly FileId[]; - knownDeletedFileSet: ReadonlySet; - resolved?: FileId | { external: string }; -}): Promise<{ type: "file"; path: string } | { type: "external"; name: string }> { - const { projectRoot, workspaceConfig, fromFile, spec, knownDeletedFiles, knownDeletedFileSet, resolved } = input; - - if (typeof resolved === "string") { - const normalizedResolved = normalizePath(resolved); - if (knownDeletedFileSet.has(normalizedResolved)) { - return { type: "file", path: normalizedResolved }; - } - } - - if (spec.startsWith(".") || spec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(spec)) { - const targetPath = spec.startsWith(".") - ? resolveReviewSpecifierTarget(fromFile, spec, knownDeletedFileSet) - : normalizePath(spec); - return { type: "file", path: targetPath }; - } - - const resolvedDeletedTarget = await resolveDeletedSnapshotBareTarget( - projectRoot, - workspaceConfig, - fromFile, - spec, - knownDeletedFiles, - ); - if (resolvedDeletedTarget) { - return { type: "file", path: resolvedDeletedTarget }; - } - - if (typeof resolved === "string") { - return { type: "file", path: normalizePath(resolved) }; - } - - if (resolved && "external" in resolved) { - return { type: "external", name: resolved.external }; - } - - return { type: "external", name: spec }; -} - -function edgeKey(edge: Edge): string { - const toKey = edge.to.type === "file" ? `file:${edge.to.path}` : `external:${edge.to.name}`; - const typeOnly = edge.typeOnly ? "1" : "0"; - return `${edge.from}|${toKey}|${edge.raw}|${typeOnly}`; -} - -function toRelativeEdge(projectRoot: string, edge: Edge): Edge { - return { - from: relativePath(projectRoot, edge.from), - to: - edge.to.type === "file" - ? { - type: "file", - path: relativePath(projectRoot, edge.to.path), - } - : edge.to, - raw: edge.raw, - ...(edge.typeOnly ? { typeOnly: edge.typeOnly } : {}), - }; -} - -async function collectDeletedImporterEdges( - index: ProjectIndex, - deletedFiles: readonly string[], - projectRoot?: string, -): Promise { - if (!deletedFiles.length) return []; - const deletedFileSet = new Set(deletedFiles.map((file) => normalizePath(file))); - const edges = new Map(); - const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; - for (const mod of index.byFile.values()) { - for (const imp of mod.imports) { - for (const deletedFile of deletedFileSet) { - const resolvedImportPath = getImportResolvedPath(imp); - const normalizedResolvedImportPath = resolvedImportPath ? normalizePath(resolvedImportPath) : undefined; - const resolvedAliasTarget = - normalizedResolvedImportPath === deletedFile - ? normalizedResolvedImportPath - : await resolveDeletedAliasImportTarget(projectRoot, workspaceConfig, mod.file, imp.from, deletedFile); - const matchesDeletedFile = matchesDeletedImportTarget(mod.file, imp.from, resolvedAliasTarget, deletedFile); - if (!matchesDeletedFile) continue; - const edge: Edge = { - from: mod.file, - to: { type: "file", path: deletedFile }, - raw: imp.from, - ...(imp.typeOnly ? { typeOnly: imp.typeOnly } : {}), - }; - edges.set(edgeKey(edge), edge); - } - } - } - return Array.from(edges.values()); -} - -async function collectDeletedSnapshotEdges( - deletedSnapshots: ReadonlyMap, - projectRoot?: string, -): Promise { - const edges = new Map(); - const deletedSnapshotFiles = Array.from(deletedSnapshots.keys()); - const deletedSnapshotFileSet = new Set(deletedSnapshotFiles); - const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; - for (const [file, snapshot] of deletedSnapshots.entries()) { - for (const imp of snapshot.module.imports) { - const to = await resolveDeletedSnapshotTarget({ - projectRoot, - workspaceConfig, - fromFile: file, - spec: imp.from, - knownDeletedFiles: deletedSnapshotFiles, - knownDeletedFileSet: deletedSnapshotFileSet, - ...(imp.resolved ? { resolved: imp.resolved } : {}), - }); - const edge: Edge = { - from: file, - to, - raw: imp.from, - ...(imp.typeOnly ? { typeOnly: imp.typeOnly } : {}), - }; - edges.set(edgeKey(edge), edge); - } - for (const entry of listReviewableExports(snapshot.module)) { - const to = await resolveDeletedSnapshotTarget({ - projectRoot, - workspaceConfig, - fromFile: file, - spec: entry.fromModule, - knownDeletedFiles: deletedSnapshotFiles, - knownDeletedFileSet: deletedSnapshotFileSet, - }); - const raw = entry.moduleSpecifier ?? entry.fromModule; - const edge: Edge = { - from: file, - to, - raw, - ...(entry.typeOnly ? { typeOnly: entry.typeOnly } : {}), - }; - edges.set(edgeKey(edge), edge); - } - } - return Array.from(edges.values()); -} - -function rangeSnippet(source: string, range: Range): string { - const startLine = range.start.line; - const endLine = range.end.line; - if (typeof startLine === "number") { - const lines = source.split(/\r?\n/); - const safeStart = Math.max(1, startLine); - const safeEnd = typeof endLine === "number" ? Math.max(safeStart, endLine) : safeStart; - return lines.slice(safeStart - 1, safeEnd).join("\n"); - } - const startIndex = range.start.index; - const endIndex = range.end.index; - if (typeof startIndex === "number" && typeof endIndex === "number" && endIndex >= startIndex) { - return source.slice(startIndex, endIndex); - } - return ""; -} - -function collectDiffSnippets(source: string, range: Range, changedLines: Set, contextLines: number): string[] { - const startLine = range.start.line ?? 0; - const endLine = range.end.line ?? startLine; - if (startLine <= 0) return []; - const safeEnd = endLine >= startLine ? endLine : startLine; - - const sortedChangedLines = [...changedLines].sort((a, b) => a - b); - if (!sortedChangedLines.length) return []; - - const lines = source.split(/\r?\n/); - const matching: number[] = []; - for (const line of sortedChangedLines) { - if (line >= startLine && line <= safeEnd) matching.push(line); - } - const matchingLines = matching.length ? matching : sortedChangedLines; - - const snippets: string[] = []; - let groupStart = matchingLines[0]!; - let groupEnd = matchingLines[0]!; - - const pushGroup = (start: number, end: number) => { - const snippetStart = Math.max(1, start - contextLines); - const snippetEnd = Math.min(lines.length, end + contextLines); - const snippet = lines.slice(snippetStart - 1, snippetEnd).join("\n"); - if (snippet) snippets.push(snippet); - }; - - for (let i = 1; i < matchingLines.length; i += 1) { - const line = matchingLines[i]!; - if (line <= groupEnd + 1) { - groupEnd = line; - } else { - pushGroup(groupStart, groupEnd); - groupStart = line; - groupEnd = line; - } - } - pushGroup(groupStart, groupEnd); - - return snippets; -} - -function sameRange(left: Range, right: Range): boolean { - const leftStart = left.start.index; - const rightStart = right.start.index; - const leftEnd = left.end.index; - const rightEnd = right.end.index; - if (typeof leftStart === "number" && typeof rightStart === "number") { - if (leftStart !== rightStart) return false; - if (typeof leftEnd === "number" && typeof rightEnd === "number") { - return leftEnd === rightEnd; - } - return true; - } - return left.start.line === right.start.line && left.start.column === right.start.column; -} - -async function runWithConcurrency(items: T[], limit: number, worker: (item: T) => Promise): Promise { - const results: R[] = []; - let nextIndex = 0; - const safeLimit = Math.max(1, limit); - const runners = Array.from({ length: Math.min(safeLimit, items.length) }, async () => { - while (true) { - const current = nextIndex; - nextIndex += 1; - if (current >= items.length) break; - const item = items[current]!; - results[current] = await worker(item); - } - }); - await Promise.all(runners); - return results; -} - -type ReviewChangeCollection = { - changedFiles: Set; - explicitFiles: Set; - diffHunksByFile: Map; - diffKindsByFile: Map; - diffChangesByFile: Map; -}; - -async function collectReviewChanges( - projectRoot: string, - appliedOptions: ReviewOptions, - reviewTimings?: ReviewTimingReport, -): Promise { - const normalizeFile = (file: string, label: string) => assertFilePathWithinRoot(projectRoot, file, label); - const discoveryIgnoreGlobs = appliedOptions.discovery?.ignoreGlobs ?? []; - const discoveryGlobRoot = appliedOptions.discovery?.globRoot ?? projectRoot; - const isIgnoredReviewFile = createImpactIgnoreMatcher(discoveryGlobRoot, discoveryIgnoreGlobs); - - const changedFiles = new Set(); - const explicitFiles = new Set(); - const changesStart = performance.now(); - for (const file of appliedOptions.files ?? []) { - const normalized = normalizeFile(file, "Review file"); - changedFiles.add(normalized); - explicitFiles.add(normalized); - } - - if (appliedOptions.gitBase || appliedOptions.changedSince) { - const gitDiffOpts: { - base?: string | undefined; - head?: string | undefined; - changedSince?: string | undefined; - } = { - base: appliedOptions.gitBase, - head: appliedOptions.gitHead, - }; - if (!appliedOptions.gitBase && appliedOptions.changedSince) { - gitDiffOpts.changedSince = appliedOptions.changedSince; - } - const gitList = await listChangedFiles(projectRoot, gitDiffOpts); - for (const file of gitList) { - if (!isIgnoredReviewFile(file)) changedFiles.add(file); - } - } - if (reviewTimings) { - reviewTimings.changesMs = Math.round(performance.now() - changesStart); - } - - const diffStart = performance.now(); - const shouldLoadGitDiff = (appliedOptions.gitBase || appliedOptions.changedSince) && changedFiles.size; - const diffText = - appliedOptions.diffText ?? - (shouldLoadGitDiff - ? await getUnifiedDiff(projectRoot, { - base: appliedOptions.gitBase, - head: appliedOptions.gitHead, - changedSince: appliedOptions.changedSince, - }) - : ""); - const diff = diffText ? parseUnifiedDiff(diffText) : null; - if (reviewTimings) { - reviewTimings.diffMs = Math.round(performance.now() - diffStart); - } - - const diffHunksByFile = new Map(); - const diffKindsByFile = new Map(); - const diffChangesByFile = new Map(); - if (diff) { - for (const fileChange of diff.files) { - const absPath = normalizeFile(fileChange.path, "Review diff file"); - const normalizedChange: FileChange = { - ...fileChange, - path: absPath, - ...(fileChange.oldPath - ? { - oldPath: normalizeFile(fileChange.oldPath, "Review old diff file"), - } - : {}), - }; - if (isIgnoredReviewFile(absPath)) { - changedFiles.delete(absPath); - continue; - } - changedFiles.add(absPath); - diffHunksByFile.set(absPath, normalizedChange.hunks); - diffKindsByFile.set(absPath, normalizedChange.kind); - diffChangesByFile.set(absPath, normalizedChange); - } - } - - return { - changedFiles, - explicitFiles, - diffHunksByFile, - diffKindsByFile, - diffChangesByFile, - }; -} - type ReviewIndexStage = { index: ProjectIndex; existenceByFile: Map; @@ -1105,413 +241,6 @@ async function buildReviewIndex(input: { }; } -async function collectReviewGraphDelta(input: { - projectRoot: string; - index: ProjectIndex; - changedFiles: ReadonlySet; - deletedFiles: readonly string[]; - deletedSnapshots: ReadonlyMap; -}): Promise { - const graphEdges = new Map(); - for (const edge of input.index.graph.edges.filter((entry) => input.changedFiles.has(entry.from))) { - const relativeEdge = toRelativeEdge(input.projectRoot, edge); - graphEdges.set(edgeKey(relativeEdge), relativeEdge); - } - for (const edge of await collectDeletedImporterEdges(input.index, input.deletedFiles, input.projectRoot)) { - const relativeEdge = toRelativeEdge(input.projectRoot, edge); - graphEdges.set(edgeKey(relativeEdge), relativeEdge); - } - for (const edge of await collectDeletedSnapshotEdges(input.deletedSnapshots, input.projectRoot)) { - const relativeEdge = toRelativeEdge(input.projectRoot, edge); - graphEdges.set(edgeKey(relativeEdge), relativeEdge); - } - return Array.from(graphEdges.values()).sort(compareEdges); -} - -async function collectReviewCandidateTests(input: { - projectRoot: string; - index: ProjectIndex; - changedFileList: string[]; - changedSymbolIds: string[]; - deletedFiles: readonly string[]; - appliedOptions: ReviewOptions; - reviewTimings?: ReviewTimingReport; -}): Promise { - const candidateStart = performance.now(); - const candidateTests = mergeCandidateTestEntries( - listCandidateTestFiles(input.index, input.changedFileList, input.changedSymbolIds, { - maxCandidates: input.appliedOptions.maxCandidates ?? 50, - ...(input.appliedOptions.testPatterns ? { testPatterns: input.appliedOptions.testPatterns } : {}), - projectRoot: input.projectRoot, - }), - await listDirectDeletedFileTestImporters( - input.index, - input.deletedFiles, - input.appliedOptions.testPatterns, - input.projectRoot, - ), - ) - .map((candidate) => ({ - ...candidate, - file: relativePath(input.projectRoot, candidate.file), - })) - .sort((left, right) => { - const confidenceCompare = confidenceRank(right.confidence) - confidenceRank(left.confidence); - if (confidenceCompare !== 0) return confidenceCompare; - const fileCompare = comparePaths(left.file, right.file); - if (fileCompare !== 0) return fileCompare; - return left.reason.localeCompare(right.reason); - }) - .slice(0, input.appliedOptions.maxCandidates ?? 50); - if (input.reviewTimings) { - input.reviewTimings.candidatesMs = Math.round(performance.now() - candidateStart); - } - return candidateTests; -} - -async function collectReviewSqlContext(input: { - projectRoot: string; - index: ProjectIndex; - changedFileList: string[]; -}): Promise { - const indexedFiles = Array.from(input.index.byFile.keys()); - const normalizedChangedFiles = new Set(input.changedFileList.map(normalizePath)); - const indexedFilesCoverMoreThanReviewSet = indexedFiles.some((file) => !normalizedChangedFiles.has(normalizePath(file))); - const sqlContextProjectFiles = - indexedFilesCoverMoreThanReviewSet && indexedFiles.some((file) => path.extname(file).toLowerCase() === ".sql") - ? indexedFiles - : undefined; - return await collectSqlReviewContext(input.projectRoot, { - changedFiles: input.changedFileList, - ...(sqlContextProjectFiles ? { projectFiles: sqlContextProjectFiles } : {}), - }); -} - -function assembleReviewReport(input: { - appliedOptions: ReviewOptions; - projectFiles: ProjectFileInfo[]; - summaries: ReviewFileSummary[]; - changedSymbolIds: string[]; - candidateTests: CandidateTestFile[]; - graphDelta: Edge[]; - sqlContext?: SqlReviewContext; - diagnostics: ReviewDiagnostics; - riskRelevantParseFailures: number; - exportedChangedCount: number; -}): ReviewReport { - const report: ReviewReport = { - schemaVersion: REVIEW_SCHEMA_VERSION, - status: "ok", - projectFiles: input.projectFiles, - summary: { - filesChanged: input.summaries.length, - symbolsChanged: input.changedSymbolIds.length, - candidateTests: input.candidateTests.length, - }, - riskSummary: computeRiskSummary({ - filesChanged: input.summaries.length, - symbolsChanged: input.changedSymbolIds.length, - exportedChanged: input.exportedChangedCount, - missingFiles: input.diagnostics.missingFiles.length, - parseFailures: input.riskRelevantParseFailures, - }), - reviewTasks: buildReviewTasks({ - filesChanged: input.summaries.length, - symbolsChanged: input.changedSymbolIds.length, - exportedChanged: input.exportedChangedCount, - candidateTests: input.candidateTests.length, - missingFiles: input.diagnostics.missingFiles.length, - parseFailures: input.riskRelevantParseFailures, - }), - changedFiles: input.summaries, - graphDelta: input.graphDelta, - candidateTests: input.candidateTests, - ...(input.sqlContext ? { sqlContext: input.sqlContext } : {}), - ...(hasDiagnostics(input.diagnostics) ? { diagnostics: input.diagnostics } : {}), - }; - if (input.appliedOptions.gitBase !== undefined) report.base = input.appliedOptions.gitBase; - report.head = input.appliedOptions.gitHead ?? "HEAD"; - return report; -} - -type ReviewChangedFileSummaries = { - summaries: ReviewFileSummary[]; - changedSymbolIds: string[]; - exportedChangedCount: number; - riskRelevantParseFailures: number; -}; - -async function summarizeChangedFiles(input: { - projectRoot: string; - index: ProjectIndex; - changedFileList: string[]; - diffHunksByFile: ReadonlyMap; - diffKindsByFile: ReadonlyMap; - explicitFiles: ReadonlySet; - existenceByFile: ReadonlyMap; - deletedSnapshots: ReadonlyMap; - includeSymbolDetails: boolean; - includeDiffContext: boolean; - diffContextLines: number; - maxCallsites: number; - referenceConcurrency: number; - diagnostics: ReviewDiagnostics; - reviewTimings?: ReviewTimingReport; -}): Promise { - const { - projectRoot, - index, - changedFileList, - diffHunksByFile, - diffKindsByFile, - explicitFiles, - existenceByFile, - deletedSnapshots, - includeSymbolDetails, - includeDiffContext, - diffContextLines, - maxCallsites, - referenceConcurrency, - diagnostics, - reviewTimings, - } = input; - const sourceCache = new Map(); - const loadSource = async (file: string): Promise => { - const cached = sourceCache.get(file); - if (cached !== undefined) return cached; - const parsed = index.parsed?.get(file); - const source = parsed?.source ?? (await fsp.readFile(file, "utf8")); - sourceCache.set(file, source); - return source; - }; - - const filesWithModules = changedFileList.map((file) => ({ - file, - mod: index.byFile.get(file), - hunks: diffHunksByFile.get(file), - })); - - const fileEntries = await Promise.all( - filesWithModules.map(async ({ file, mod, hunks }) => { - if (!mod) { - return { - file, - mod, - hunks, - locals: [] as SymbolDef[], - handles: [] as string[], - diffLinesByHandle: new Map>(), - parseFailed: false, - }; - } - if (!hunks) { - const locals = sortSymbols(mod.locals); - return { - file, - mod, - hunks, - locals, - handles: locals.map((local) => symbolId(local)), - diffLinesByHandle: new Map>(), - parseFailed: false, - }; - } - const { changedSymbols, changedLines, parseFailed } = await locateChangedSymbolsWithLines(index, file, hunks); - if (parseFailed) { - diagnostics.symbolMappingParseFailures.push(relativePath(projectRoot, file)); - } - const uniqueSymbols = new Map(); - for (const symbol of changedSymbols) { - uniqueSymbols.set(symbol.id, { - file: symbol.file, - localName: symbol.name, - kind: symbol.kind, - range: symbol.range, - }); - } - const locals = sortSymbols(Array.from(uniqueSymbols.values())); - const handles = locals.map((local) => symbolId(local)); - return { - file, - mod, - hunks, - locals, - handles, - diffLinesByHandle: await mapChangedLinesToSymbols(index, file, hunks, changedLines), - parseFailed, - }; - }), - ); - - const defsToResolve = fileEntries.flatMap((entry) => entry.locals); - const referencesStart = performance.now(); - const referenceResults = - includeSymbolDetails && maxCallsites > 0 - ? await runWithConcurrency(defsToResolve, referenceConcurrency, async (def) => { - const refs = await findReferences( - index, - { def }, - { - maxReferences: maxCallsites + 1, - }, - ); - return { def, refs }; - }) - : []; - if (reviewTimings) { - reviewTimings.referencesMs = Math.round(performance.now() - referencesStart); - } - const referencesByHandle = new Map> }>(); - for (const entry of referenceResults) { - referencesByHandle.set(symbolId(entry.def), entry); - } - - const buildSymbolSummary = async ( - local: SymbolDef, - moduleIndex: ModuleIndex, - diffLinesByHandle: Map>, - ): Promise => { - const handle = symbolId(local); - const base: ReviewSymbolSummary = { - name: local.localName, - kind: local.kind, - handle, - exported: isExported(moduleIndex, handle), - }; - if (!includeSymbolDetails) return base; - - const source = await loadSource(local.file); - const snippet = rangeSnippet(source, local.range); - const definitionSnippet = snippet ? { definitionSnippet: snippet } : {}; - const diffLines = diffLinesByHandle.get(handle) ?? new Set(); - const diffSnippets = - includeDiffContext && diffLines.size - ? collectDiffSnippets(source, local.range, diffLines, diffContextLines) - : []; - - let callsites: ReviewSymbolCallsite[] | undefined; - if (maxCallsites > 0) { - const entry = referencesByHandle.get(handle); - const refs = entry?.refs; - if (refs?.status === "ok") { - const candidates = refs.references.filter( - (ref) => !(ref.file === local.file && sameRange(ref.range, local.range)), - ); - const limited = candidates.slice(0, maxCallsites).map((ref) => ({ - file: relativePath(projectRoot, ref.file), - range: ref.range, - })); - if (limited.length) callsites = limited; - } - } - - return { - ...base, - ...definitionSnippet, - ...(diffSnippets.length ? { diffSnippets } : {}), - ...(callsites ? { callsites } : {}), - }; - }; - - const summariesWithHandles = await Promise.all( - fileEntries.map(async ({ file, mod, hunks, locals, handles, diffLinesByHandle }) => { - const deletedSnapshot = deletedSnapshots.get(file); - if (!mod && deletedSnapshot) { - const deletedLocals = sortSymbols(deletedSnapshot.module.locals); - const localSymbols: ReviewSymbolSummary[] = includeSymbolDetails - ? deletedLocals.map((local) => { - const handle = symbolId(local); - const definitionSnippet = rangeSnippet(deletedSnapshot.source, local.range); - return { - name: local.localName, - kind: local.kind, - handle, - exported: isExported(deletedSnapshot.module, handle), - ...(definitionSnippet ? { definitionSnippet } : {}), - }; - }) - : deletedLocals.map((local) => { - const handle = symbolId(local); - return { - name: local.localName, - kind: local.kind, - handle, - exported: isExported(deletedSnapshot.module, handle), - }; - }); - const exportSymbols = buildExportSummaries(file, deletedSnapshot.module); - const symbols = [...localSymbols, ...exportSymbols]; - const handles = [ - ...deletedLocals.map((local) => symbolId(local)), - ...exportSymbols.map((symbol) => symbol.handle), - ]; - return { - summary: { - file: relativePath(projectRoot, file), - status: "deleted", - symbols, - } satisfies ReviewFileSummary, - handles, - }; - } - if (!mod) { - const fileExistsOnDisk = existenceByFile.get(file) ?? false; - const isDeletedByDiff = diffKindsByFile.get(file) === "deleted"; - const isMissingExplicitInput = !fileExistsOnDisk && explicitFiles.has(file) && !isDeletedByDiff; - if (isMissingExplicitInput) { - diagnostics.missingFiles.push(relativePath(projectRoot, file)); - } - return { - summary: { - file: relativePath(projectRoot, file), - status: isMissingExplicitInput ? "missing" : "deleted", - symbols: [], - } satisfies ReviewFileSummary, - handles: [] as string[], - }; - } - const localSymbols: ReviewSymbolSummary[] = includeSymbolDetails - ? await Promise.all(locals.map((local) => buildSymbolSummary(local, mod, diffLinesByHandle))) - : locals.map((local) => { - const handle = symbolId(local); - return { - name: local.localName, - kind: local.kind, - handle, - exported: isExported(mod, handle), - }; - }); - const exportSymbols = shouldIncludeExportSummaries(mod, hunks, locals) ? buildExportSummaries(file, mod) : []; - const symbols = [...localSymbols, ...exportSymbols]; - return { - summary: { - file: relativePath(projectRoot, file), - status: "updated", - symbols, - } satisfies ReviewFileSummary, - handles: [...handles, ...exportSymbols.map((symbol) => symbol.handle)], - }; - }), - ); - - const summaries = summariesWithHandles.map((entry) => entry.summary); - const changedSymbolIds = summariesWithHandles.flatMap((entry) => entry.handles); - const exportedChangedCount = summaries.reduce((count, summary) => { - const exportedInFile = summary.symbols.filter((symbol) => symbol.exported); - return count + exportedInFile.length; - }, 0); - const riskRelevantParseFailures = diagnostics.symbolMappingParseFailures.filter((file) => - isRiskRelevantSymbolMappingFile(path.join(projectRoot, file)), - ).length; - - return { - summaries, - changedSymbolIds, - exportedChangedCount, - riskRelevantParseFailures, - }; -} - /** * Build the structured review report used by programmatic review agents. * diff --git a/src/review/candidates.ts b/src/review/candidates.ts new file mode 100644 index 00000000..657cdb74 --- /dev/null +++ b/src/review/candidates.ts @@ -0,0 +1,82 @@ +import { performance } from "node:perf_hooks"; +import { listCandidateTestFiles, type CandidateTestFile } from "../impact/context.js"; +import type { ProjectIndex } from "../indexer.js"; +import type { FileId } from "../types.js"; +import { normalizePath, toProjectRelativePath } from "../util.js"; +import type { ReviewOptions, ReviewTimingReport } from "../review.js"; +import { listDirectDeletedFileTestImporters } from "./deleted.js"; + +function relativePath(root: string, file: string): string { + return toProjectRelativePath(root, file) ?? normalizePath(file); +} + +function comparePaths(left: string, right: string): number { + return left.localeCompare(right); +} + +function confidenceRank(confidence: CandidateTestFile["confidence"]): number { + if (confidence === "high") return 3; + if (confidence === "medium") return 2; + return 1; +} + +function mergeCandidateTestEntries( + baseCandidates: CandidateTestFile[], + additionalCandidates: CandidateTestFile[], +): CandidateTestFile[] { + const merged = new Map(); + const upsert = (candidate: CandidateTestFile) => { + const existing = merged.get(candidate.file); + if (!existing) { + merged.set(candidate.file, candidate); + return; + } + if (confidenceRank(candidate.confidence) > confidenceRank(existing.confidence)) { + merged.set(candidate.file, candidate); + } + }; + for (const candidate of baseCandidates) upsert(candidate); + for (const candidate of additionalCandidates) upsert(candidate); + return Array.from(merged.values()); +} + +export async function collectReviewCandidateTests(input: { + projectRoot: string; + index: ProjectIndex; + changedFileList: string[]; + changedSymbolIds: string[]; + deletedFiles: readonly string[]; + appliedOptions: ReviewOptions; + reviewTimings?: ReviewTimingReport; +}): Promise { + const candidateStart = performance.now(); + const candidateTests = mergeCandidateTestEntries( + listCandidateTestFiles(input.index, input.changedFileList, input.changedSymbolIds, { + maxCandidates: input.appliedOptions.maxCandidates ?? 50, + ...(input.appliedOptions.testPatterns ? { testPatterns: input.appliedOptions.testPatterns } : {}), + projectRoot: input.projectRoot, + }), + await listDirectDeletedFileTestImporters( + input.index, + input.deletedFiles, + input.appliedOptions.testPatterns, + input.projectRoot, + ), + ) + .map((candidate) => ({ + ...candidate, + file: relativePath(input.projectRoot, candidate.file), + })) + .sort((left, right) => { + const confidenceCompare = confidenceRank(right.confidence) - confidenceRank(left.confidence); + if (confidenceCompare !== 0) return confidenceCompare; + const fileCompare = comparePaths(left.file, right.file); + if (fileCompare !== 0) return fileCompare; + return left.reason.localeCompare(right.reason); + }) + .slice(0, input.appliedOptions.maxCandidates ?? 50); + if (input.reviewTimings) { + input.reviewTimings.candidatesMs = Math.round(performance.now() - candidateStart); + } + return candidateTests; +} diff --git a/src/review/changes.ts b/src/review/changes.ts new file mode 100644 index 00000000..7ae28dd0 --- /dev/null +++ b/src/review/changes.ts @@ -0,0 +1,105 @@ +import { performance } from "node:perf_hooks"; +import { createImpactIgnoreMatcher } from "../impact/path.js"; +import { parseUnifiedDiff } from "../impact/parse.js"; +import type { FileChange, Hunk } from "../impact/types.js"; +import type { ReviewOptions, ReviewTimingReport } from "../review.js"; +import { assertFilePathWithinRoot, getUnifiedDiff, listChangedFiles } from "../util.js"; + +export type ReviewChangeCollection = { + changedFiles: Set; + explicitFiles: Set; + diffHunksByFile: Map; + diffKindsByFile: Map; + diffChangesByFile: Map; +}; + +export async function collectReviewChanges( + projectRoot: string, + appliedOptions: ReviewOptions, + reviewTimings?: ReviewTimingReport, +): Promise { + const normalizeFile = (file: string, label: string) => assertFilePathWithinRoot(projectRoot, file, label); + const discoveryIgnoreGlobs = appliedOptions.discovery?.ignoreGlobs ?? []; + const discoveryGlobRoot = appliedOptions.discovery?.globRoot ?? projectRoot; + const isIgnoredReviewFile = createImpactIgnoreMatcher(discoveryGlobRoot, discoveryIgnoreGlobs); + + const changedFiles = new Set(); + const explicitFiles = new Set(); + const changesStart = performance.now(); + for (const file of appliedOptions.files ?? []) { + const normalized = normalizeFile(file, "Review file"); + changedFiles.add(normalized); + explicitFiles.add(normalized); + } + + if (appliedOptions.gitBase || appliedOptions.changedSince) { + const gitDiffOpts: { + base?: string | undefined; + head?: string | undefined; + changedSince?: string | undefined; + } = { + base: appliedOptions.gitBase, + head: appliedOptions.gitHead, + }; + if (!appliedOptions.gitBase && appliedOptions.changedSince) { + gitDiffOpts.changedSince = appliedOptions.changedSince; + } + const gitList = await listChangedFiles(projectRoot, gitDiffOpts); + for (const file of gitList) { + if (!isIgnoredReviewFile(file)) changedFiles.add(file); + } + } + if (reviewTimings) { + reviewTimings.changesMs = Math.round(performance.now() - changesStart); + } + + const diffStart = performance.now(); + const shouldLoadGitDiff = (appliedOptions.gitBase || appliedOptions.changedSince) && changedFiles.size; + const diffText = + appliedOptions.diffText ?? + (shouldLoadGitDiff + ? await getUnifiedDiff(projectRoot, { + base: appliedOptions.gitBase, + head: appliedOptions.gitHead, + changedSince: appliedOptions.changedSince, + }) + : ""); + const diff = diffText ? parseUnifiedDiff(diffText) : null; + if (reviewTimings) { + reviewTimings.diffMs = Math.round(performance.now() - diffStart); + } + + const diffHunksByFile = new Map(); + const diffKindsByFile = new Map(); + const diffChangesByFile = new Map(); + if (diff) { + for (const fileChange of diff.files) { + const absPath = normalizeFile(fileChange.path, "Review diff file"); + const normalizedChange: FileChange = { + ...fileChange, + path: absPath, + ...(fileChange.oldPath + ? { + oldPath: normalizeFile(fileChange.oldPath, "Review old diff file"), + } + : {}), + }; + if (isIgnoredReviewFile(absPath)) { + changedFiles.delete(absPath); + continue; + } + changedFiles.add(absPath); + diffHunksByFile.set(absPath, normalizedChange.hunks); + diffKindsByFile.set(absPath, normalizedChange.kind); + diffChangesByFile.set(absPath, normalizedChange); + } + } + + return { + changedFiles, + explicitFiles, + diffHunksByFile, + diffKindsByFile, + diffChangesByFile, + }; +} diff --git a/src/review/deleted.ts b/src/review/deleted.ts new file mode 100644 index 00000000..88c22963 --- /dev/null +++ b/src/review/deleted.ts @@ -0,0 +1,443 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { CandidateTestFile } from "../impact/context.js"; +import { compileTestPatterns, createIndexTestFileMatcher } from "../impact/testPatterns.js"; +import type { FileChange } from "../impact/types.js"; +import { + collectImportsForFile, + collectLocalsAndExportsFromSource, + type ExportEntry, + type ImportBinding, + type ModuleIndex, + type ProjectIndex, +} from "../indexer.js"; +import { supportForFile } from "../languages.js"; +import type { Edge, FileId } from "../types.js"; +import { + listResolutionCandidates, + listWorkspacePackageResolutionCandidates, + loadNearestTsconfigFor, + loadWorkspaceConfig, + normalizePath, + toProjectRelativePath, + type WorkspaceConfig, +} from "../util.js"; +import type { GraphBuildOptions } from "../graphs/types.js"; + +const execFileAsync = promisify(execFile); + +export type DeletedFileSnapshot = { + source: string; + module: ModuleIndex; +}; + +type ReviewableExportEntry = Exclude; + +function relativePath(root: string, file: string): string { + return toProjectRelativePath(root, file) ?? normalizePath(file); +} + +function normalizeSpecifierBase(fromFile: string, spec: string): string { + return normalizePath(path.resolve(path.dirname(fromFile), spec)); +} + +function buildDeletedImportCandidates(fromFile: string, spec: string, targetFile: string): Set { + const normalizedSpec = spec.replace(/\\/g, "/"); + const basePath = normalizeSpecifierBase(fromFile, normalizedSpec); + const resolutionExtensions = deletedImportResolutionExtensions(targetFile); + const candidates = listResolutionCandidates(basePath, resolutionExtensions).map((candidate) => + normalizePath(candidate), + ); + return new Set(candidates); +} + +function matchesDeletedImportTarget( + fromFile: string, + spec: string, + resolved: string | undefined, + deletedFile: string, +): boolean { + if (resolved && normalizePath(resolved) === deletedFile) { + return true; + } + if (!spec.startsWith(".")) { + return false; + } + return buildDeletedImportCandidates(fromFile, spec, deletedFile).has(deletedFile); +} + +function getImportResolvedPath(entry: Pick): string | undefined { + return typeof entry.resolved === "string" ? entry.resolved : undefined; +} + +function buildDeletedAliasCandidates(candidate: string, targetFile: string): Set { + const normalizedCandidate = normalizePath(candidate); + const resolutionExtensions = deletedImportResolutionExtensions(targetFile); + const resolutionCandidates = listResolutionCandidates(normalizedCandidate, resolutionExtensions); + const resolvedCandidates = resolutionCandidates.map((resolvedCandidate) => normalizePath(resolvedCandidate)); + return new Set(resolvedCandidates); +} + +function deletedImportResolutionExtensions(targetFile: string): string[] { + const targetExt = path.extname(targetFile); + return targetExt ? [targetExt] : []; +} + +async function resolveDeletedAliasImportTarget( + projectRoot: string | undefined, + workspaceConfig: WorkspaceConfig | undefined, + fromFile: string, + spec: string, + deletedFile: string, +): Promise { + if (spec.startsWith(".") || spec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(spec)) { + return undefined; + } + const deletedTarget = normalizePath(deletedFile); + const resolutionExtensions = deletedImportResolutionExtensions(deletedFile); + const { matchPath } = await loadNearestTsconfigFor(fromFile); + if (matchPath) { + const matched = matchPath( + spec, + undefined, + (candidate) => buildDeletedAliasCandidates(candidate, deletedFile).has(deletedTarget), + resolutionExtensions, + ); + if (matched) { + const resolvedMatch = Array.from(buildDeletedAliasCandidates(matched, deletedFile)).find( + (candidate) => candidate === deletedTarget, + ); + if (resolvedMatch) { + return resolvedMatch; + } + } + } + + if (!projectRoot) { + return undefined; + } + + return listWorkspacePackageResolutionCandidates(spec, workspaceConfig, resolutionExtensions) + .map((candidate) => normalizePath(candidate)) + .find((candidate) => candidate === deletedTarget); +} + +export async function listDirectDeletedFileTestImporters( + index: ProjectIndex, + deletedFiles: readonly string[], + testPatterns: string[] = [], + projectRoot?: string, +): Promise { + if (!deletedFiles.length) return []; + + const deletedFileSet = new Set(deletedFiles.map((file) => normalizePath(file))); + const compiledPatterns = compileTestPatterns(testPatterns); + const isIndexTestFile = createIndexTestFileMatcher(index, compiledPatterns, projectRoot); + const candidates = new Map(); + const importsByFile = new Map>(); + const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; + + for (const edge of index.graph.edges) { + let imports = importsByFile.get(edge.from); + if (!imports) { + imports = []; + importsByFile.set(edge.from, imports); + } + imports.push({ + spec: edge.raw, + ...(edge.to.type === "file" ? { resolved: edge.to.path } : {}), + }); + } + + for (const mod of index.byFile.values()) { + if (!isIndexTestFile(mod.file)) continue; + const uniqueImports = new Map(); + for (const entry of importsByFile.get(mod.file) ?? []) { + uniqueImports.set(`${entry.spec}::${entry.resolved ?? ""}`, entry); + } + for (const imp of mod.imports) { + const resolved = getImportResolvedPath(imp); + uniqueImports.set(`${imp.from}::${resolved ?? ""}`, { + spec: imp.from, + ...(resolved ? { resolved } : {}), + }); + } + for (const entry of uniqueImports.values()) { + for (const deletedFile of deletedFileSet) { + const resolvedImportPath = entry.resolved ? normalizePath(entry.resolved) : undefined; + const resolvedAliasTarget = + resolvedImportPath === deletedFile + ? resolvedImportPath + : await resolveDeletedAliasImportTarget(projectRoot, workspaceConfig, mod.file, entry.spec, deletedFile); + if (!matchesDeletedImportTarget(mod.file, entry.spec, resolvedAliasTarget, deletedFile)) { + continue; + } + candidates.set(mod.file, { + file: mod.file, + confidence: "high", + reason: "importsChanged", + }); + } + } + } + + return Array.from(candidates.values()); +} + +async function readGitFileAtRevision(projectRoot: string, revision: string, file: string): Promise { + const relativeFile = normalizePath(path.relative(projectRoot, file)); + if (!relativeFile || relativeFile.startsWith("..")) return null; + try { + const { stdout } = await execFileAsync("git", ["show", `${revision}:${relativeFile}`], { + cwd: projectRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; + } catch { + return null; + } +} + +export async function buildDeletedFileSnapshots( + projectRoot: string, + deletedFiles: readonly string[], + opts: { + revision?: string; + diffChangesByFile?: ReadonlyMap; + graphOptions?: GraphBuildOptions; + }, +): Promise> { + const snapshots = new Map(); + if (!deletedFiles.length) return snapshots; + + for (const file of deletedFiles) { + const support = supportForFile(file); + if (!support) continue; + const source = + (opts.revision ? await readGitFileAtRevision(projectRoot, opts.revision, file) : null) ?? + reconstructDeletedSourceFromDiff(opts.diffChangesByFile?.get(file)); + if (source === null) continue; + const normalizedFile = normalizePath(file); + const imports = await collectImportsForFile(normalizedFile, projectRoot, { + source, + sup: support, + ...(opts.graphOptions ? { graphOptions: opts.graphOptions } : {}), + }); + const module = collectLocalsAndExportsFromSource(normalizedFile, source, support, undefined, imports); + snapshots.set(normalizedFile, { + source, + module, + }); + } + + return snapshots; +} + +function reconstructDeletedSourceFromDiff(change: FileChange | undefined): string | null { + if (!change || change.kind !== "deleted" || !change.hunks.length) { + return null; + } + const oldLines: string[] = []; + for (const hunk of change.hunks) { + let oldLine = hunk.oldStart; + for (const line of hunk.lines) { + const prefix = line[0]; + if (prefix === "+") continue; + if (prefix !== " " && prefix !== "-") continue; + while (oldLines.length < oldLine - 1) { + oldLines.push(""); + } + oldLines[oldLine - 1] = line.slice(1); + oldLine += 1; + } + } + return oldLines.length ? oldLines.join("\n") : null; +} + +function resolveReviewSpecifierTarget(fromFile: string, spec: string, knownDeletedFiles?: ReadonlySet): string { + const normalizedSpec = spec.replace(/\\/g, "/"); + const basePath = normalizeSpecifierBase(fromFile, normalizedSpec); + const candidates = listResolutionCandidates(basePath).map((candidate) => normalizePath(candidate)); + if (knownDeletedFiles) { + for (const candidate of candidates) { + if (knownDeletedFiles.has(candidate)) return candidate; + } + } + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return candidates[0] ?? basePath; +} + +async function resolveDeletedSnapshotBareTarget( + projectRoot: string | undefined, + workspaceConfig: WorkspaceConfig | undefined, + fromFile: string, + spec: string, + knownDeletedFiles: readonly FileId[], +): Promise { + for (const deletedFile of knownDeletedFiles) { + const resolved = await resolveDeletedAliasImportTarget(projectRoot, workspaceConfig, fromFile, spec, deletedFile); + if (resolved === deletedFile) { + return deletedFile; + } + } + return undefined; +} + +async function resolveDeletedSnapshotTarget(input: { + projectRoot: string | undefined; + workspaceConfig: WorkspaceConfig | undefined; + fromFile: string; + spec: string; + knownDeletedFiles: readonly FileId[]; + knownDeletedFileSet: ReadonlySet; + resolved?: FileId | { external: string }; +}): Promise<{ type: "file"; path: string } | { type: "external"; name: string }> { + const { projectRoot, workspaceConfig, fromFile, spec, knownDeletedFiles, knownDeletedFileSet, resolved } = input; + + if (typeof resolved === "string") { + const normalizedResolved = normalizePath(resolved); + if (knownDeletedFileSet.has(normalizedResolved)) { + return { type: "file", path: normalizedResolved }; + } + } + + if (spec.startsWith(".") || spec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(spec)) { + const targetPath = spec.startsWith(".") + ? resolveReviewSpecifierTarget(fromFile, spec, knownDeletedFileSet) + : normalizePath(spec); + return { type: "file", path: targetPath }; + } + + const resolvedDeletedTarget = await resolveDeletedSnapshotBareTarget( + projectRoot, + workspaceConfig, + fromFile, + spec, + knownDeletedFiles, + ); + if (resolvedDeletedTarget) { + return { type: "file", path: resolvedDeletedTarget }; + } + + if (typeof resolved === "string") { + return { type: "file", path: normalizePath(resolved) }; + } + + if (resolved && "external" in resolved) { + return { type: "external", name: resolved.external }; + } + + return { type: "external", name: spec }; +} + +export function edgeKey(edge: Edge): string { + const toKey = edge.to.type === "file" ? `file:${edge.to.path}` : `external:${edge.to.name}`; + const typeOnly = edge.typeOnly ? "1" : "0"; + return `${edge.from}|${toKey}|${edge.raw}|${typeOnly}`; +} + +export function toRelativeEdge(projectRoot: string, edge: Edge): Edge { + return { + from: relativePath(projectRoot, edge.from), + to: + edge.to.type === "file" + ? { + type: "file", + path: relativePath(projectRoot, edge.to.path), + } + : edge.to, + raw: edge.raw, + ...(edge.typeOnly ? { typeOnly: edge.typeOnly } : {}), + }; +} + +export async function collectDeletedImporterEdges( + index: ProjectIndex, + deletedFiles: readonly string[], + projectRoot?: string, +): Promise { + if (!deletedFiles.length) return []; + const deletedFileSet = new Set(deletedFiles.map((file) => normalizePath(file))); + const edges = new Map(); + const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; + for (const mod of index.byFile.values()) { + for (const imp of mod.imports) { + for (const deletedFile of deletedFileSet) { + const resolvedImportPath = getImportResolvedPath(imp); + const normalizedResolvedImportPath = resolvedImportPath ? normalizePath(resolvedImportPath) : undefined; + const resolvedAliasTarget = + normalizedResolvedImportPath === deletedFile + ? normalizedResolvedImportPath + : await resolveDeletedAliasImportTarget(projectRoot, workspaceConfig, mod.file, imp.from, deletedFile); + const matchesDeletedFile = matchesDeletedImportTarget(mod.file, imp.from, resolvedAliasTarget, deletedFile); + if (!matchesDeletedFile) continue; + const edge: Edge = { + from: mod.file, + to: { type: "file", path: deletedFile }, + raw: imp.from, + ...(imp.typeOnly ? { typeOnly: imp.typeOnly } : {}), + }; + edges.set(edgeKey(edge), edge); + } + } + } + return Array.from(edges.values()); +} + +function listReviewableExports(mod: ModuleIndex): ReviewableExportEntry[] { + return mod.exports.filter((entry): entry is ReviewableExportEntry => entry.type !== "local"); +} + +export async function collectDeletedSnapshotEdges( + deletedSnapshots: ReadonlyMap, + projectRoot?: string, +): Promise { + const edges = new Map(); + const deletedSnapshotFiles = Array.from(deletedSnapshots.keys()); + const deletedSnapshotFileSet = new Set(deletedSnapshotFiles); + const workspaceConfig = projectRoot ? await loadWorkspaceConfig(projectRoot) : undefined; + for (const [file, snapshot] of deletedSnapshots.entries()) { + for (const imp of snapshot.module.imports) { + const to = await resolveDeletedSnapshotTarget({ + projectRoot, + workspaceConfig, + fromFile: file, + spec: imp.from, + knownDeletedFiles: deletedSnapshotFiles, + knownDeletedFileSet: deletedSnapshotFileSet, + ...(imp.resolved ? { resolved: imp.resolved } : {}), + }); + const edge: Edge = { + from: file, + to, + raw: imp.from, + ...(imp.typeOnly ? { typeOnly: imp.typeOnly } : {}), + }; + edges.set(edgeKey(edge), edge); + } + for (const entry of listReviewableExports(snapshot.module)) { + const to = await resolveDeletedSnapshotTarget({ + projectRoot, + workspaceConfig, + fromFile: file, + spec: entry.fromModule, + knownDeletedFiles: deletedSnapshotFiles, + knownDeletedFileSet: deletedSnapshotFileSet, + }); + const raw = entry.moduleSpecifier ?? entry.fromModule; + const edge: Edge = { + from: file, + to, + raw, + ...(entry.typeOnly ? { typeOnly: entry.typeOnly } : {}), + }; + edges.set(edgeKey(edge), edge); + } + } + return Array.from(edges.values()); +} diff --git a/src/review/report.ts b/src/review/report.ts new file mode 100644 index 00000000..65d1f676 --- /dev/null +++ b/src/review/report.ts @@ -0,0 +1,129 @@ +import path from "node:path"; +import type { CandidateTestFile } from "../impact/context.js"; +import type { ProjectIndex } from "../indexer.js"; +import { collectSqlReviewContext, type SqlReviewContext } from "../sql/review.js"; +import type { Edge, FileId } from "../types.js"; +import { normalizePath, type ProjectFileInfo } from "../util.js"; +import type { ReviewDiagnostics, ReviewOptions, ReviewReport } from "../review.js"; +import { + collectDeletedImporterEdges, + collectDeletedSnapshotEdges, + edgeKey, + toRelativeEdge, + type DeletedFileSnapshot, +} from "./deleted.js"; +import { buildReviewTasks, computeRiskSummary, hasDiagnostics } from "./risk.js"; +import type { ReviewFileSummary } from "./summaries.js"; + +export const REVIEW_SCHEMA_VERSION = 2; + +function comparePaths(left: string, right: string): number { + return left.localeCompare(right); +} + +function compareEdges(left: Edge, right: Edge): number { + const fromCompare = comparePaths(left.from, right.from); + if (fromCompare !== 0) return fromCompare; + if (left.to.type !== right.to.type) { + return left.to.type === "file" ? -1 : 1; + } + const leftTarget = left.to.type === "file" ? left.to.path : left.to.name; + const rightTarget = right.to.type === "file" ? right.to.path : right.to.name; + const toCompare = comparePaths(leftTarget, rightTarget); + if (toCompare !== 0) return toCompare; + const rawCompare = left.raw.localeCompare(right.raw); + if (rawCompare !== 0) return rawCompare; + const leftTypeOnly = left.typeOnly ? 1 : 0; + const rightTypeOnly = right.typeOnly ? 1 : 0; + return leftTypeOnly - rightTypeOnly; +} + +export async function collectReviewGraphDelta(input: { + projectRoot: string; + index: ProjectIndex; + changedFiles: ReadonlySet; + deletedFiles: readonly string[]; + deletedSnapshots: ReadonlyMap; +}): Promise { + const graphEdges = new Map(); + for (const edge of input.index.graph.edges.filter((entry) => input.changedFiles.has(entry.from))) { + const relativeEdge = toRelativeEdge(input.projectRoot, edge); + graphEdges.set(edgeKey(relativeEdge), relativeEdge); + } + for (const edge of await collectDeletedImporterEdges(input.index, input.deletedFiles, input.projectRoot)) { + const relativeEdge = toRelativeEdge(input.projectRoot, edge); + graphEdges.set(edgeKey(relativeEdge), relativeEdge); + } + for (const edge of await collectDeletedSnapshotEdges(input.deletedSnapshots, input.projectRoot)) { + const relativeEdge = toRelativeEdge(input.projectRoot, edge); + graphEdges.set(edgeKey(relativeEdge), relativeEdge); + } + return Array.from(graphEdges.values()).sort(compareEdges); +} + +export async function collectReviewSqlContext(input: { + projectRoot: string; + index: ProjectIndex; + changedFileList: string[]; +}): Promise { + const indexedFiles = Array.from(input.index.byFile.keys()); + const normalizedChangedFiles = new Set(input.changedFileList.map(normalizePath)); + const indexedFilesCoverMoreThanReviewSet = indexedFiles.some( + (file) => !normalizedChangedFiles.has(normalizePath(file)), + ); + const sqlContextProjectFiles = + indexedFilesCoverMoreThanReviewSet && indexedFiles.some((file) => path.extname(file).toLowerCase() === ".sql") + ? indexedFiles + : undefined; + return await collectSqlReviewContext(input.projectRoot, { + changedFiles: input.changedFileList, + ...(sqlContextProjectFiles ? { projectFiles: sqlContextProjectFiles } : {}), + }); +} + +export function assembleReviewReport(input: { + appliedOptions: ReviewOptions; + projectFiles: ProjectFileInfo[]; + summaries: ReviewFileSummary[]; + changedSymbolIds: string[]; + candidateTests: CandidateTestFile[]; + graphDelta: Edge[]; + sqlContext?: SqlReviewContext; + diagnostics: ReviewDiagnostics; + riskRelevantParseFailures: number; + exportedChangedCount: number; +}): ReviewReport { + const report: ReviewReport = { + schemaVersion: REVIEW_SCHEMA_VERSION, + status: "ok", + projectFiles: input.projectFiles, + summary: { + filesChanged: input.summaries.length, + symbolsChanged: input.changedSymbolIds.length, + candidateTests: input.candidateTests.length, + }, + riskSummary: computeRiskSummary({ + filesChanged: input.summaries.length, + symbolsChanged: input.changedSymbolIds.length, + exportedChanged: input.exportedChangedCount, + missingFiles: input.diagnostics.missingFiles.length, + parseFailures: input.riskRelevantParseFailures, + }), + reviewTasks: buildReviewTasks({ + filesChanged: input.summaries.length, + symbolsChanged: input.changedSymbolIds.length, + exportedChanged: input.exportedChangedCount, + candidateTests: input.candidateTests.length, + missingFiles: input.diagnostics.missingFiles.length, + parseFailures: input.riskRelevantParseFailures, + }), + changedFiles: input.summaries, + graphDelta: input.graphDelta, + candidateTests: input.candidateTests, + ...(input.sqlContext ? { sqlContext: input.sqlContext } : {}), + ...(hasDiagnostics(input.diagnostics) ? { diagnostics: input.diagnostics } : {}), + }; + if (input.appliedOptions.gitBase !== undefined) report.base = input.appliedOptions.gitBase; + report.head = input.appliedOptions.gitHead ?? "HEAD"; + return report; +} diff --git a/src/review/risk.ts b/src/review/risk.ts new file mode 100644 index 00000000..bbf08be4 --- /dev/null +++ b/src/review/risk.ts @@ -0,0 +1,125 @@ +import { supportForFile } from "../languages.js"; +import type { ReviewDiagnostics, ReviewRiskLevel, ReviewRiskSummary, ReviewTask } from "../review.js"; + +export function computeRiskSummary(input: { + filesChanged: number; + symbolsChanged: number; + exportedChanged: number; + missingFiles: number; + parseFailures: number; +}): ReviewRiskSummary { + const signals: string[] = []; + let score = 0; + if (input.exportedChanged > 0) { + score += 60; + signals.push("exported-symbols-changed"); + } else { + score += 20; + } + if (input.symbolsChanged >= 20) { + score += 20; + signals.push("many-symbols-changed"); + } + if (input.filesChanged >= 10) { + score += 20; + signals.push("many-files-changed"); + } + if (input.missingFiles > 0) { + score += 30; + signals.push("missing-files"); + } + if (input.parseFailures > 0) { + score += 25; + signals.push("symbol-mapping-degraded"); + } + const normalizedScore = Math.min(100, score); + let level: ReviewRiskLevel = "low"; + if (normalizedScore >= 70) level = "high"; + else if (normalizedScore >= 40) level = "medium"; + return { + level, + score: normalizedScore, + signals, + }; +} + +export function buildReviewTasks(input: { + filesChanged: number; + symbolsChanged: number; + exportedChanged: number; + candidateTests: number; + missingFiles: number; + parseFailures: number; +}): ReviewTask[] { + const tasks: ReviewTask[] = [ + { + id: "review-summary", + title: "Review changed symbols", + description: "Scan the changed symbols and confirm behavioral changes align with intent.", + priority: "medium", + reason: "baseline-review", + }, + ]; + + if (input.exportedChanged > 0) { + tasks.push({ + id: "api-compat", + title: "Verify API compatibility", + description: "Check exported symbols for breaking changes, migration notes, and versioning implications.", + priority: "high", + reason: "exported-symbols-changed", + }); + } + + if (input.candidateTests === 0) { + tasks.push({ + id: "tests-missing", + title: "Validate test coverage", + description: "No candidate tests were detected. Confirm existing coverage or add targeted tests.", + priority: "medium", + reason: "no-candidate-tests", + }); + } + + if (input.filesChanged >= 10 || input.symbolsChanged >= 20) { + tasks.push({ + id: "high-change-volume", + title: "Assess change scope", + description: "Large change set detected. Double-check impacted files and coordination needs.", + priority: "high", + reason: "large-change-set", + }); + } + + if (input.parseFailures > 0) { + tasks.push({ + id: "analysis-degraded", + title: "Validate degraded symbol mapping", + description: + "Some changed files could not be mapped cleanly to symbols. Review syntax errors, parser support, or fall back to file-level inspection.", + priority: "high", + reason: "symbol-mapping-degraded", + }); + } + + if (input.missingFiles > 0) { + tasks.push({ + id: "missing-input-files", + title: "Validate missing review inputs", + description: + "Some explicitly requested files were missing on disk. Confirm paths and whether the intended change was a real deletion.", + priority: "high", + reason: "missing-files", + }); + } + + return tasks; +} + +export function hasDiagnostics(diagnostics: ReviewDiagnostics): boolean { + return !!(diagnostics.missingFiles.length || diagnostics.symbolMappingParseFailures.length); +} + +export function isRiskRelevantSymbolMappingFile(file: string): boolean { + return supportForFile(file)?.supportsCrossModuleSymbols ?? false; +} diff --git a/src/review/summaries.ts b/src/review/summaries.ts new file mode 100644 index 00000000..66007b02 --- /dev/null +++ b/src/review/summaries.ts @@ -0,0 +1,465 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { isSymbolHandleExported } from "../indexer/declarations.js"; +import { + findReferences, + type ExportEntry, + type ModuleIndex, + type ProjectIndex, + type SymbolDef, + symbolId, +} from "../indexer.js"; +import { locateChangedSymbolsWithLines, mapChangedLinesToSymbols } from "../impact/map.js"; +import type { Hunk } from "../impact/types.js"; +import type { FileId, Range } from "../types.js"; +import { normalizePath, toProjectRelativePath } from "../util.js"; +import type { ReviewDiagnostics, ReviewTimingReport } from "../review.js"; +import type { DeletedFileSnapshot } from "./deleted.js"; +import { isRiskRelevantSymbolMappingFile } from "./risk.js"; + +export type ReviewFileSummary = { + file: string; + status: "updated" | "deleted" | "missing"; + symbols: ReviewSymbolSummary[]; +}; + +type ReviewSymbolCallsite = { + file: string; + range: Range; +}; + +export type ReviewSymbolSummary = { + name: string; + kind: string; + handle: string; + exported: boolean; + definitionSnippet?: string; + diffSnippets?: string[]; + callsites?: ReviewSymbolCallsite[]; +}; + +export type ReviewChangedFileSummaries = { + summaries: ReviewFileSummary[]; + changedSymbolIds: string[]; + exportedChangedCount: number; + riskRelevantParseFailures: number; +}; + +type ReviewableExportEntry = Exclude; + +function relativePath(root: string, file: string): string { + return toProjectRelativePath(root, file) ?? normalizePath(file); +} + +function sortSymbols(symbols: SymbolDef[]): SymbolDef[] { + return symbols.slice().sort((left, right) => symbolId(left).localeCompare(symbolId(right))); +} + +function isExported(mod: { exports: ExportEntry[] }, handle: string): boolean { + return isSymbolHandleExported(mod.exports, handle); +} + +function listReviewableExports(mod: ModuleIndex): ReviewableExportEntry[] { + return mod.exports.filter((entry): entry is ReviewableExportEntry => entry.type !== "local"); +} + +function exportSummaryHandle(file: string, entry: ReviewableExportEntry): string { + const exportedAs = entry.type === "exportStar" ? "*" : entry.exportedAs; + return `${file}::export::${entry.type}::${exportedAs}::${entry.fromModule}`; +} + +function exportSummaryName(entry: ReviewableExportEntry): string { + return entry.type === "exportStar" ? "*" : entry.exportedAs; +} + +function exportSummaryKind(entry: ReviewableExportEntry): string { + return entry.type; +} + +function diffLineLooksExportLike(line: string): boolean { + const prefix = line[0]; + if (prefix !== "+" && prefix !== "-") return false; + const trimmed = line.slice(1).trimStart(); + return trimmed.startsWith("export ") || trimmed.startsWith("module.exports") || trimmed.startsWith("exports."); +} + +function shouldIncludeExportSummaries( + mod: ModuleIndex, + hunks: Hunk[] | undefined, + locals: readonly SymbolDef[], +): boolean { + if (!listReviewableExports(mod).length) return false; + if (!hunks) return true; + if (!locals.length) return true; + return hunks.some((hunk) => hunk.lines.some(diffLineLooksExportLike)); +} + +function buildExportSummaries(file: string, mod: ModuleIndex): ReviewSymbolSummary[] { + return listReviewableExports(mod).map((entry) => ({ + name: exportSummaryName(entry), + kind: exportSummaryKind(entry), + handle: exportSummaryHandle(file, entry), + exported: true, + })); +} + +function rangeSnippet(source: string, range: Range): string { + const startLine = range.start.line; + const endLine = range.end.line; + if (typeof startLine === "number") { + const lines = source.split(/\r?\n/); + const safeStart = Math.max(1, startLine); + const safeEnd = typeof endLine === "number" ? Math.max(safeStart, endLine) : safeStart; + return lines.slice(safeStart - 1, safeEnd).join("\n"); + } + const startIndex = range.start.index; + const endIndex = range.end.index; + if (typeof startIndex === "number" && typeof endIndex === "number" && endIndex >= startIndex) { + return source.slice(startIndex, endIndex); + } + return ""; +} + +function collectDiffSnippets(source: string, range: Range, changedLines: Set, contextLines: number): string[] { + const startLine = range.start.line ?? 0; + const endLine = range.end.line ?? startLine; + if (startLine <= 0) return []; + const safeEnd = endLine >= startLine ? endLine : startLine; + + const sortedChangedLines = [...changedLines].sort((a, b) => a - b); + if (!sortedChangedLines.length) return []; + + const lines = source.split(/\r?\n/); + const matching: number[] = []; + for (const line of sortedChangedLines) { + if (line >= startLine && line <= safeEnd) matching.push(line); + } + const matchingLines = matching.length ? matching : sortedChangedLines; + + const snippets: string[] = []; + let groupStart = matchingLines[0]!; + let groupEnd = matchingLines[0]!; + + const pushGroup = (start: number, end: number) => { + const snippetStart = Math.max(1, start - contextLines); + const snippetEnd = Math.min(lines.length, end + contextLines); + const snippet = lines.slice(snippetStart - 1, snippetEnd).join("\n"); + if (snippet) snippets.push(snippet); + }; + + for (let i = 1; i < matchingLines.length; i += 1) { + const line = matchingLines[i]!; + if (line <= groupEnd + 1) { + groupEnd = line; + } else { + pushGroup(groupStart, groupEnd); + groupStart = line; + groupEnd = line; + } + } + pushGroup(groupStart, groupEnd); + + return snippets; +} + +function sameRange(left: Range, right: Range): boolean { + const leftStart = left.start.index; + const rightStart = right.start.index; + const leftEnd = left.end.index; + const rightEnd = right.end.index; + if (typeof leftStart === "number" && typeof rightStart === "number") { + if (leftStart !== rightStart) return false; + if (typeof leftEnd === "number" && typeof rightEnd === "number") { + return leftEnd === rightEnd; + } + return true; + } + return left.start.line === right.start.line && left.start.column === right.start.column; +} + +async function runWithConcurrency(items: T[], limit: number, worker: (item: T) => Promise): Promise { + const results: R[] = []; + let nextIndex = 0; + const safeLimit = Math.max(1, limit); + const runners = Array.from({ length: Math.min(safeLimit, items.length) }, async () => { + while (true) { + const current = nextIndex; + nextIndex += 1; + if (current >= items.length) break; + const item = items[current]!; + results[current] = await worker(item); + } + }); + await Promise.all(runners); + return results; +} + +export async function summarizeChangedFiles(input: { + projectRoot: string; + index: ProjectIndex; + changedFileList: string[]; + diffHunksByFile: ReadonlyMap; + diffKindsByFile: ReadonlyMap; + explicitFiles: ReadonlySet; + existenceByFile: ReadonlyMap; + deletedSnapshots: ReadonlyMap; + includeSymbolDetails: boolean; + includeDiffContext: boolean; + diffContextLines: number; + maxCallsites: number; + referenceConcurrency: number; + diagnostics: ReviewDiagnostics; + reviewTimings?: ReviewTimingReport; +}): Promise { + const { + projectRoot, + index, + changedFileList, + diffHunksByFile, + diffKindsByFile, + explicitFiles, + existenceByFile, + deletedSnapshots, + includeSymbolDetails, + includeDiffContext, + diffContextLines, + maxCallsites, + referenceConcurrency, + diagnostics, + reviewTimings, + } = input; + const sourceCache = new Map(); + const loadSource = async (file: string): Promise => { + const cached = sourceCache.get(file); + if (cached !== undefined) return cached; + const parsed = index.parsed?.get(file); + const source = parsed?.source ?? (await fsp.readFile(file, "utf8")); + sourceCache.set(file, source); + return source; + }; + + const filesWithModules = changedFileList.map((file) => ({ + file, + mod: index.byFile.get(file), + hunks: diffHunksByFile.get(file), + })); + + const fileEntries = await Promise.all( + filesWithModules.map(async ({ file, mod, hunks }) => { + if (!mod) { + return { + file, + mod, + hunks, + locals: [] as SymbolDef[], + handles: [] as string[], + diffLinesByHandle: new Map>(), + parseFailed: false, + }; + } + if (!hunks) { + const locals = sortSymbols(mod.locals); + return { + file, + mod, + hunks, + locals, + handles: locals.map((local) => symbolId(local)), + diffLinesByHandle: new Map>(), + parseFailed: false, + }; + } + const { changedSymbols, changedLines, parseFailed } = await locateChangedSymbolsWithLines(index, file, hunks); + if (parseFailed) { + diagnostics.symbolMappingParseFailures.push(relativePath(projectRoot, file)); + } + const uniqueSymbols = new Map(); + for (const symbol of changedSymbols) { + uniqueSymbols.set(symbol.id, { + file: symbol.file, + localName: symbol.name, + kind: symbol.kind, + range: symbol.range, + }); + } + const locals = sortSymbols(Array.from(uniqueSymbols.values())); + const handles = locals.map((local) => symbolId(local)); + return { + file, + mod, + hunks, + locals, + handles, + diffLinesByHandle: await mapChangedLinesToSymbols(index, file, hunks, changedLines), + parseFailed, + }; + }), + ); + + const defsToResolve = fileEntries.flatMap((entry) => entry.locals); + const referencesStart = performance.now(); + const referenceResults = + includeSymbolDetails && maxCallsites > 0 + ? await runWithConcurrency(defsToResolve, referenceConcurrency, async (def) => { + const refs = await findReferences( + index, + { def }, + { + maxReferences: maxCallsites + 1, + }, + ); + return { def, refs }; + }) + : []; + if (reviewTimings) { + reviewTimings.referencesMs = Math.round(performance.now() - referencesStart); + } + const referencesByHandle = new Map> }>(); + for (const entry of referenceResults) { + referencesByHandle.set(symbolId(entry.def), entry); + } + + const buildSymbolSummary = async ( + local: SymbolDef, + moduleIndex: ModuleIndex, + diffLinesByHandle: Map>, + ): Promise => { + const handle = symbolId(local); + const base: ReviewSymbolSummary = { + name: local.localName, + kind: local.kind, + handle, + exported: isExported(moduleIndex, handle), + }; + if (!includeSymbolDetails) return base; + + const source = await loadSource(local.file); + const snippet = rangeSnippet(source, local.range); + const definitionSnippet = snippet ? { definitionSnippet: snippet } : {}; + const diffLines = diffLinesByHandle.get(handle) ?? new Set(); + const diffSnippets = + includeDiffContext && diffLines.size ? collectDiffSnippets(source, local.range, diffLines, diffContextLines) : []; + + let callsites: ReviewSymbolCallsite[] | undefined; + if (maxCallsites > 0) { + const entry = referencesByHandle.get(handle); + const refs = entry?.refs; + if (refs?.status === "ok") { + const candidates = refs.references.filter( + (ref) => !(ref.file === local.file && sameRange(ref.range, local.range)), + ); + const limited = candidates.slice(0, maxCallsites).map((ref) => ({ + file: relativePath(projectRoot, ref.file), + range: ref.range, + })); + if (limited.length) callsites = limited; + } + } + + return { + ...base, + ...definitionSnippet, + ...(diffSnippets.length ? { diffSnippets } : {}), + ...(callsites ? { callsites } : {}), + }; + }; + + const summariesWithHandles = await Promise.all( + fileEntries.map(async ({ file, mod, hunks, locals, handles, diffLinesByHandle }) => { + const deletedSnapshot = deletedSnapshots.get(file); + if (!mod && deletedSnapshot) { + const deletedLocals = sortSymbols(deletedSnapshot.module.locals); + const localSymbols: ReviewSymbolSummary[] = includeSymbolDetails + ? deletedLocals.map((local) => { + const handle = symbolId(local); + const definitionSnippet = rangeSnippet(deletedSnapshot.source, local.range); + return { + name: local.localName, + kind: local.kind, + handle, + exported: isExported(deletedSnapshot.module, handle), + ...(definitionSnippet ? { definitionSnippet } : {}), + }; + }) + : deletedLocals.map((local) => { + const handle = symbolId(local); + return { + name: local.localName, + kind: local.kind, + handle, + exported: isExported(deletedSnapshot.module, handle), + }; + }); + const exportSymbols = buildExportSummaries(file, deletedSnapshot.module); + const symbols = [...localSymbols, ...exportSymbols]; + const handles = [ + ...deletedLocals.map((local) => symbolId(local)), + ...exportSymbols.map((symbol) => symbol.handle), + ]; + return { + summary: { + file: relativePath(projectRoot, file), + status: "deleted", + symbols, + } satisfies ReviewFileSummary, + handles, + }; + } + if (!mod) { + const fileExistsOnDisk = existenceByFile.get(file) ?? false; + const isDeletedByDiff = diffKindsByFile.get(file) === "deleted"; + const isMissingExplicitInput = !fileExistsOnDisk && explicitFiles.has(file) && !isDeletedByDiff; + if (isMissingExplicitInput) { + diagnostics.missingFiles.push(relativePath(projectRoot, file)); + } + return { + summary: { + file: relativePath(projectRoot, file), + status: isMissingExplicitInput ? "missing" : "deleted", + symbols: [], + } satisfies ReviewFileSummary, + handles: [] as string[], + }; + } + const localSymbols: ReviewSymbolSummary[] = includeSymbolDetails + ? await Promise.all(locals.map((local) => buildSymbolSummary(local, mod, diffLinesByHandle))) + : locals.map((local) => { + const handle = symbolId(local); + return { + name: local.localName, + kind: local.kind, + handle, + exported: isExported(mod, handle), + }; + }); + const exportSymbols = shouldIncludeExportSummaries(mod, hunks, locals) ? buildExportSummaries(file, mod) : []; + const symbols = [...localSymbols, ...exportSymbols]; + return { + summary: { + file: relativePath(projectRoot, file), + status: "updated", + symbols, + } satisfies ReviewFileSummary, + handles: [...handles, ...exportSymbols.map((symbol) => symbol.handle)], + }; + }), + ); + + const summaries = summariesWithHandles.map((entry) => entry.summary); + const changedSymbolIds = summariesWithHandles.flatMap((entry) => entry.handles); + const exportedChangedCount = summaries.reduce((count, summary) => { + const exportedInFile = summary.symbols.filter((symbol) => symbol.exported); + return count + exportedInFile.length; + }, 0); + const riskRelevantParseFailures = diagnostics.symbolMappingParseFailures.filter((file) => + isRiskRelevantSymbolMappingFile(path.join(projectRoot, file)), + ).length; + + return { + summaries, + changedSymbolIds, + exportedChangedCount, + riskRelevantParseFailures, + }; +} From 719f234cc498baf407d1f509f899c9eca765a49d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 17:41:58 -0400 Subject: [PATCH 08/25] Split tsconfig node and python resolution --- src/util/resolution.ts | 319 +------------------------------- src/util/resolution/node.ts | 83 +++++++++ src/util/resolution/python.ts | 127 +++++++++++++ src/util/resolution/tsconfig.ts | 104 +++++++++++ 4 files changed, 322 insertions(+), 311 deletions(-) create mode 100644 src/util/resolution/node.ts create mode 100644 src/util/resolution/python.ts create mode 100644 src/util/resolution/tsconfig.ts diff --git a/src/util/resolution.ts b/src/util/resolution.ts index d443370f..0c22fe36 100644 --- a/src/util/resolution.ts +++ b/src/util/resolution.ts @@ -1,33 +1,28 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { createMatchPath } from "tsconfig-paths"; -import { logWithLevel, type LogLevel } from "../logging.js"; import { stringifyUnknown } from "./ast.js"; -import { parseJsonc } from "./comments.js"; import { normalizePath, normalizeResolutionHints } from "./paths.js"; import { listProjectFiles } from "./projectFiles.js"; import { DEFAULT_RESOLUTION_EXTENSIONS, listResolutionCandidates } from "./resolutionCandidates.js"; import { clearWorkspaceCaches, clearFileExistsCache, - directoryExists, fileExists, - loadJSON, loadWorkspaceConfig, resolveWorkspacePackage, - type MinimalPackageJson, type WorkspaceConfig, } from "./workspace.js"; -import { - clearJvmResolutionCaches, - resolveJavaImportPath, - resolveKotlinImportPath, -} from "./resolution/jvm.js"; +import { clearJvmResolutionCaches, resolveJavaImportPath, resolveKotlinImportPath } from "./resolution/jvm.js"; import { resolveGoImportPath } from "./resolution/go.js"; -import { findNearestFile, isDirectory } from "./resolution/files.js"; +import { findNearestFile } from "./resolution/files.js"; +import { resolveFromNodeModules } from "./resolution/node.js"; +import { clearPythonResolutionCache, resolvePythonModule } from "./resolution/python.js"; +import { clearTsconfigCache, loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; export { resolveGoImportPath } from "./resolution/go.js"; export { resolveJvmPackageImportPaths } from "./resolution/jvm.js"; +export { resolvePythonModule } from "./resolution/python.js"; +export { loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; import { addProjectSymbolFile, getOrCreateProjectSymbolIndex, @@ -38,108 +33,7 @@ import { export { listResolutionCandidates } from "./resolutionCandidates.js"; -export type MatchPathFn = ReturnType; -const tsconfigCache = new Map(); - -async function findNearestTsconfig(startFromFile: string): Promise { - let dir = path.dirname(startFromFile); - while (true) { - const cand = path.join(dir, "tsconfig.json"); - try { - await fsp.access(cand, fs.constants.R_OK); - return cand; - } catch { - /* file not found: continue up */ - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - -interface TsconfigCompilerOptions { - baseUrl?: string; - paths?: Record; -} - -interface TsconfigJson { - compilerOptions?: TsconfigCompilerOptions; - extends?: string; -} - -async function loadTsconfigConfig(cfgPath: string): Promise<{ baseUrl: string; paths: Record }> { - const raw = await fsp.readFile(cfgPath, "utf8"); - const json = parseJsonc(raw); - const cfgDir = path.dirname(cfgPath); - const co = json.compilerOptions; - const baseUrlRaw = co?.baseUrl ?? "."; - const baseUrl = path.isAbsolute(baseUrlRaw) ? baseUrlRaw : path.resolve(cfgDir, baseUrlRaw); - const paths: Record = co?.paths ?? {}; - - if (json.extends) { - const extendsPath = path.resolve(cfgDir, json.extends); - if (await fileExists(extendsPath)) { - const parent = await loadTsconfigConfig(extendsPath); - const mergedPaths: Record = { ...parent.paths }; - - // Adjust parent paths to be relative to child baseUrl - for (const [key, patterns] of Object.entries(parent.paths)) { - mergedPaths[key] = patterns.map((p) => { - const abs = path.resolve(parent.baseUrl, p); - const rel = path.relative(baseUrl, abs).replace(/\\/g, "/"); - return rel; - }); - } - - // Child paths overwrite parent paths for the same key - // and ensure they are also normalized - for (const [key, patterns] of Object.entries(paths)) { - mergedPaths[key] = patterns.map((p) => p.replace(/\\/g, "/")); - } - return { baseUrl: baseUrl.replace(/\\/g, "/"), paths: mergedPaths }; - } - } - - const normalizedPaths: Record = {}; - for (const [key, patterns] of Object.entries(paths)) { - normalizedPaths[key] = patterns.map((p) => p.replace(/\\/g, "/")); - } - - return { baseUrl: baseUrl.replace(/\\/g, "/"), paths: normalizedPaths }; -} - -export async function loadNearestTsconfigFor(file: string, logLevel?: LogLevel): Promise<{ matchPath?: MatchPathFn }> { - const dir = path.dirname(file); - if (tsconfigCache.has(dir)) return tsconfigCache.get(dir)!; - - const cfgPath = await findNearestTsconfig(file); - if (!cfgPath) { - tsconfigCache.set(dir, {}); - return {}; - } - - try { - const { baseUrl, paths } = await loadTsconfigConfig(cfgPath); - const matchPath = createMatchPath(baseUrl, paths); - const val = { matchPath }; - tsconfigCache.set(dir, val); - return val; - } catch (error) { - logWithLevel(logLevel, "warn", `Warning: Failed to load tsconfig at ${cfgPath}:`, error); - const val = {}; - tsconfigCache.set(dir, val); - return val; - } -} - -function clearTsconfigCache(): void { - tsconfigCache.clear(); -} - const resolveSpecifierCache = new Map(); -const resolvePythonModuleCache = new Map(); - export type FileId = string; export const GRAPH_ONLY_RESOLUTION_EXTENSIONS = [ @@ -1185,206 +1079,9 @@ export async function resolveSpecifier( return ext; } -async function resolveFromNodeModules( - spec: string, - fromFile: string, - _projectRoot: string, - resolutionExtensions?: readonly string[], -): Promise { - try { - // Walk up from the file directory to project root looking for node_modules - let dir = path.dirname(fromFile); - const parts = spec.split("/"); - const packageName = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!; - const subpath = spec.startsWith("@") ? parts.slice(2).join("/") : parts.slice(1).join("/"); - while (true) { - const nmDir = path.join(dir, "node_modules", packageName); - if (await directoryExists(nmDir)) { - const pkgPath = path.join(nmDir, "package.json"); - const pkg = await loadJSON(pkgPath); - const baseDir = nmDir; - const tryResolveRelative = async (rel: string): Promise => { - return await findFirstExistingResolutionCandidate(path.resolve(baseDir, rel), resolutionExtensions); - }; - // Exports map handling (simplified) - const pickExportTarget = (target: unknown): string | null => { - if (!target) return null; - if (typeof target === "string") return target; - if (typeof target === "object" && target !== null) { - const t = target as Record; - const cand = t.import ?? t.default ?? t.require ?? t.module; - if (typeof cand === "string") return cand; - } - return null; - }; - if (pkg?.exports) { - const key = subpath ? `./${subpath}` : "."; - if (typeof pkg.exports === "string" && key === ".") { - const hit = await tryResolveRelative(pkg.exports); - if (hit) return hit; - } else if (typeof pkg.exports === "object" && pkg.exports !== null) { - const map = pkg.exports as Record; - const target = map[key] ?? (key === "." ? map["."] : undefined); - const rel = pickExportTarget(target); - if (rel) { - const hit = await tryResolveRelative(rel); - if (hit) return hit; - } - } - } - if (subpath) { - const hit = await findFirstExistingResolutionCandidate(path.join(baseDir, subpath), resolutionExtensions); - if (hit) return hit; - } - const mainField = typeof pkg?.main === "string" ? path.resolve(baseDir, pkg.main) : null; - if (mainField) { - const mainHit = await findFirstExistingResolutionCandidate(mainField, resolutionExtensions); - if (mainHit) return mainHit; - } - const indexHit = await findFirstExistingResolutionCandidate(path.join(baseDir, "index"), resolutionExtensions); - if (indexHit) return indexHit; - return null; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } catch { - /* fs/access: ignore */ - } - return null; -} -async function findPythonPackageAnchor(startDir: string): Promise { - let dir = startDir; - let topWithInit = startDir; - while (true) { - try { - await fsp.access(path.join(dir, "__init__.py"), fs.constants.R_OK); - topWithInit = dir; - } catch { - /* no __init__.py: continue */ - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return topWithInit; -} - -export async function resolvePythonModule( - projectRoot: string, - fromFile: string, - moduleName: string | null, - importDotCount: number, -): Promise { - const cacheKey = `${fromFile}::${".".repeat(importDotCount)}${moduleName ?? ""}`; - const cached = resolvePythonModuleCache.get(cacheKey); - if (cached) return cached; - const fromDir = path.dirname(fromFile); - - // If it's a relative import (dots > 0), start from current file's dir and walk up. - // importDotCount = 1 means same dir (.), 2 means parent (..), etc. - let startDir = fromDir; - if (importDotCount > 0) { - // 1 dot = current dir (0 steps up) - // 2 dots = parent dir (1 step up) - const stepsUp = Math.max(0, importDotCount - 1); - for (let i = 0; i < stepsUp; i++) { - startDir = path.dirname(startDir); - } - } else { - // Absolute import: start from project root or find anchor? - // Python sys.path usually includes current script dir, but for "absolute" imports - // in a project structure, we usually mean relative to project root or nearest site-packages. - // Here we try relative to project root first. - startDir = projectRoot; - } - - const parts = (moduleName ? moduleName.split(".") : []).filter(Boolean); - const relPath = parts.length ? path.join(...parts) : ""; - - // Candidates relative to the resolved start directory - const candidates: string[] = []; - if (relPath) { - candidates.push(path.join(startDir, relPath + ".py")); - candidates.push(path.join(startDir, relPath, "__init__.py")); - candidates.push(path.join(startDir, relPath)); - } else if (importDotCount > 0) { - // "from . import x" or "from .. import x" where moduleName is null - // This resolves to the package defined by __init__.py in startDir - candidates.push(path.join(startDir, "__init__.py")); - } - - for (const c of candidates) { - try { - if (await isDirectory(c)) { - const res = normalizePath(path.resolve(c)); - resolvePythonModuleCache.set(cacheKey, res); - return res; - } - await fsp.access(c, fs.constants.R_OK); - { - const res = normalizePath(path.resolve(c)); - resolvePythonModuleCache.set(cacheKey, res); - return res; - } - } catch { - /* access failed: try next */ - } - } - - // If absolute import, also try finding anchor in case project root isn't the package root - if (importDotCount === 0 && moduleName) { - let anchor: string; - try { - anchor = await findPythonPackageAnchor(fromDir); - } catch { - anchor = projectRoot; - } - - const parts = moduleName.split("."); - // Try relative to anchor parent (package structure) - const parentPath = path.join(path.dirname(anchor), ...parts); - // Try relative to anchor itself (script/root structure) - const anchorPath = path.join(anchor, ...parts); - - const anchorCandidates = [ - parentPath + ".py", - path.join(parentPath, "__init__.py"), - parentPath, - anchorPath + ".py", - path.join(anchorPath, "__init__.py"), - anchorPath, - ]; - for (const c of anchorCandidates) { - try { - if (await isDirectory(c)) { - const res = normalizePath(path.resolve(c)); - resolvePythonModuleCache.set(cacheKey, res); - return res; - } - await fsp.access(c, fs.constants.R_OK); - { - const res = normalizePath(path.resolve(c)); - resolvePythonModuleCache.set(cacheKey, res); - return res; - } - } catch { - /* access failed: try next */ - } - } - } - - const ext = { - external: ".".repeat(importDotCount) + (moduleName ?? ""), - } as const; - resolvePythonModuleCache.set(cacheKey, ext); - return ext; -} - export function clearImportResolutionCaches(): void { resolveSpecifierCache.clear(); - resolvePythonModuleCache.clear(); + clearPythonResolutionCache(); clearFileExistsCache(); clearJvmResolutionCaches(); phpImportResolutionCache.clear(); diff --git a/src/util/resolution/node.ts b/src/util/resolution/node.ts new file mode 100644 index 00000000..d9f9e432 --- /dev/null +++ b/src/util/resolution/node.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import { listResolutionCandidates } from "../resolutionCandidates.js"; +import { directoryExists, fileExists, loadJSON, type MinimalPackageJson } from "../workspace.js"; + +async function findFirstExistingResolutionCandidate( + base: string, + resolutionExtensions?: readonly string[], +): Promise { + for (const candidate of listResolutionCandidates(base, resolutionExtensions)) { + if (await fileExists(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +export async function resolveFromNodeModules( + spec: string, + fromFile: string, + _projectRoot: string, + resolutionExtensions?: readonly string[], +): Promise { + try { + let dir = path.dirname(fromFile); + const parts = spec.split("/"); + const packageName = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!; + const subpath = spec.startsWith("@") ? parts.slice(2).join("/") : parts.slice(1).join("/"); + while (true) { + const nmDir = path.join(dir, "node_modules", packageName); + if (await directoryExists(nmDir)) { + const pkgPath = path.join(nmDir, "package.json"); + const pkg = await loadJSON(pkgPath); + const baseDir = nmDir; + const tryResolveRelative = async (rel: string): Promise => { + return await findFirstExistingResolutionCandidate(path.resolve(baseDir, rel), resolutionExtensions); + }; + const pickExportTarget = (target: unknown): string | null => { + if (!target) return null; + if (typeof target === "string") return target; + if (typeof target === "object" && target !== null) { + const t = target as Record; + const cand = t.import ?? t.default ?? t.require ?? t.module; + if (typeof cand === "string") return cand; + } + return null; + }; + if (pkg?.exports) { + const key = subpath ? `./${subpath}` : "."; + if (typeof pkg.exports === "string" && key === ".") { + const hit = await tryResolveRelative(pkg.exports); + if (hit) return hit; + } else if (typeof pkg.exports === "object" && pkg.exports !== null) { + const map = pkg.exports as Record; + const target = map[key] ?? (key === "." ? map["."] : undefined); + const rel = pickExportTarget(target); + if (rel) { + const hit = await tryResolveRelative(rel); + if (hit) return hit; + } + } + } + if (subpath) { + const hit = await findFirstExistingResolutionCandidate(path.join(baseDir, subpath), resolutionExtensions); + if (hit) return hit; + } + const mainField = typeof pkg?.main === "string" ? path.resolve(baseDir, pkg.main) : null; + if (mainField) { + const mainHit = await findFirstExistingResolutionCandidate(mainField, resolutionExtensions); + if (mainHit) return mainHit; + } + const indexHit = await findFirstExistingResolutionCandidate(path.join(baseDir, "index"), resolutionExtensions); + if (indexHit) return indexHit; + return null; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } catch { + /* fs/access: ignore */ + } + return null; +} diff --git a/src/util/resolution/python.ts b/src/util/resolution/python.ts new file mode 100644 index 00000000..4d0f519d --- /dev/null +++ b/src/util/resolution/python.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { normalizePath } from "../paths.js"; +import { isDirectory } from "./files.js"; + +type FileId = string; + +const resolvePythonModuleCache = new Map(); + +async function findPythonPackageAnchor(startDir: string): Promise { + let dir = startDir; + let topWithInit = startDir; + while (true) { + try { + await fsp.access(path.join(dir, "__init__.py"), fs.constants.R_OK); + topWithInit = dir; + } catch { + /* no __init__.py: continue */ + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return topWithInit; +} + +export async function resolvePythonModule( + projectRoot: string, + fromFile: string, + moduleName: string | null, + importDotCount: number, +): Promise { + const cacheKey = `${fromFile}::${".".repeat(importDotCount)}${moduleName ?? ""}`; + const cached = resolvePythonModuleCache.get(cacheKey); + if (cached) return cached; + const fromDir = path.dirname(fromFile); + + let startDir = fromDir; + if (importDotCount > 0) { + const stepsUp = Math.max(0, importDotCount - 1); + for (let i = 0; i < stepsUp; i++) { + startDir = path.dirname(startDir); + } + } else { + startDir = projectRoot; + } + + const parts = (moduleName ? moduleName.split(".") : []).filter(Boolean); + const relPath = parts.length ? path.join(...parts) : ""; + + const candidates: string[] = []; + if (relPath) { + candidates.push(path.join(startDir, relPath + ".py")); + candidates.push(path.join(startDir, relPath, "__init__.py")); + candidates.push(path.join(startDir, relPath)); + } else if (importDotCount > 0) { + candidates.push(path.join(startDir, "__init__.py")); + } + + for (const c of candidates) { + try { + if (await isDirectory(c)) { + const res = normalizePath(path.resolve(c)); + resolvePythonModuleCache.set(cacheKey, res); + return res; + } + await fsp.access(c, fs.constants.R_OK); + { + const res = normalizePath(path.resolve(c)); + resolvePythonModuleCache.set(cacheKey, res); + return res; + } + } catch { + /* access failed: try next */ + } + } + + if (importDotCount === 0 && moduleName) { + let anchor: string; + try { + anchor = await findPythonPackageAnchor(fromDir); + } catch { + anchor = projectRoot; + } + + const parts = moduleName.split("."); + const parentPath = path.join(path.dirname(anchor), ...parts); + const anchorPath = path.join(anchor, ...parts); + + const anchorCandidates = [ + parentPath + ".py", + path.join(parentPath, "__init__.py"), + parentPath, + anchorPath + ".py", + path.join(anchorPath, "__init__.py"), + anchorPath, + ]; + for (const c of anchorCandidates) { + try { + if (await isDirectory(c)) { + const res = normalizePath(path.resolve(c)); + resolvePythonModuleCache.set(cacheKey, res); + return res; + } + await fsp.access(c, fs.constants.R_OK); + { + const res = normalizePath(path.resolve(c)); + resolvePythonModuleCache.set(cacheKey, res); + return res; + } + } catch { + /* access failed: try next */ + } + } + } + + const ext = { + external: ".".repeat(importDotCount) + (moduleName ?? ""), + } as const; + resolvePythonModuleCache.set(cacheKey, ext); + return ext; +} + +export function clearPythonResolutionCache(): void { + resolvePythonModuleCache.clear(); +} diff --git a/src/util/resolution/tsconfig.ts b/src/util/resolution/tsconfig.ts new file mode 100644 index 00000000..68deebab --- /dev/null +++ b/src/util/resolution/tsconfig.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { createMatchPath } from "tsconfig-paths"; +import { logWithLevel, type LogLevel } from "../../logging.js"; +import { parseJsonc } from "../comments.js"; +import { fileExists } from "../workspace.js"; + +export type MatchPathFn = ReturnType; + +const tsconfigCache = new Map(); + +async function findNearestTsconfig(startFromFile: string): Promise { + let dir = path.dirname(startFromFile); + while (true) { + const cand = path.join(dir, "tsconfig.json"); + try { + await fsp.access(cand, fs.constants.R_OK); + return cand; + } catch { + /* file not found: continue up */ + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +interface TsconfigCompilerOptions { + baseUrl?: string; + paths?: Record; +} + +interface TsconfigJson { + compilerOptions?: TsconfigCompilerOptions; + extends?: string; +} + +async function loadTsconfigConfig(cfgPath: string): Promise<{ baseUrl: string; paths: Record }> { + const raw = await fsp.readFile(cfgPath, "utf8"); + const json = parseJsonc(raw); + const cfgDir = path.dirname(cfgPath); + const co = json.compilerOptions; + const baseUrlRaw = co?.baseUrl ?? "."; + const baseUrl = path.isAbsolute(baseUrlRaw) ? baseUrlRaw : path.resolve(cfgDir, baseUrlRaw); + const paths: Record = co?.paths ?? {}; + + if (json.extends) { + const extendsPath = path.resolve(cfgDir, json.extends); + if (await fileExists(extendsPath)) { + const parent = await loadTsconfigConfig(extendsPath); + const mergedPaths: Record = { ...parent.paths }; + + for (const [key, patterns] of Object.entries(parent.paths)) { + mergedPaths[key] = patterns.map((p) => { + const abs = path.resolve(parent.baseUrl, p); + const rel = path.relative(baseUrl, abs).replace(/\\/g, "/"); + return rel; + }); + } + + for (const [key, patterns] of Object.entries(paths)) { + mergedPaths[key] = patterns.map((p) => p.replace(/\\/g, "/")); + } + return { baseUrl: baseUrl.replace(/\\/g, "/"), paths: mergedPaths }; + } + } + + const normalizedPaths: Record = {}; + for (const [key, patterns] of Object.entries(paths)) { + normalizedPaths[key] = patterns.map((p) => p.replace(/\\/g, "/")); + } + + return { baseUrl: baseUrl.replace(/\\/g, "/"), paths: normalizedPaths }; +} + +export async function loadNearestTsconfigFor(file: string, logLevel?: LogLevel): Promise<{ matchPath?: MatchPathFn }> { + const dir = path.dirname(file); + if (tsconfigCache.has(dir)) return tsconfigCache.get(dir)!; + + const cfgPath = await findNearestTsconfig(file); + if (!cfgPath) { + tsconfigCache.set(dir, {}); + return {}; + } + + try { + const { baseUrl, paths } = await loadTsconfigConfig(cfgPath); + const matchPath = createMatchPath(baseUrl, paths); + const val = { matchPath }; + tsconfigCache.set(dir, val); + return val; + } catch (error) { + logWithLevel(logLevel, "warn", `Warning: Failed to load tsconfig at ${cfgPath}:`, error); + const val = {}; + tsconfigCache.set(dir, val); + return val; + } +} + +export function clearTsconfigCache(): void { + tsconfigCache.clear(); +} From ae1777ea255214a0f434f6560925f84ddfda9c57 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 17:48:03 -0400 Subject: [PATCH 09/25] Split PHP resolution module --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/util/resolution.ts | 781 +---------------------------------- src/util/resolution/php.ts | 822 +++++++++++++++++++++++++++++++++++++ 3 files changed, 826 insertions(+), 779 deletions(-) create mode 100644 src/util/resolution/php.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index ffd19db9..f74ec40c 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -74,7 +74,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - `src/review/report.ts` for final assembly. - Add focused tests around deleted-file cases, missing explicit files, review presets, candidate ordering, and diagnostics. -- [ ] Split `src/util/resolution.ts` into language/domain-specific modules. +- [x] Split `src/util/resolution.ts` into language/domain-specific modules. - Current shape: TS config paths, graph-only resolution, PHP Composer/class discovery, Python module resolution, Node package resolution, cache clearing, and generic `mapLimit` live together. - Target split: - `src/util/resolution/tsconfig.ts` diff --git a/src/util/resolution.ts b/src/util/resolution.ts index 0c22fe36..c6a780b0 100644 --- a/src/util/resolution.ts +++ b/src/util/resolution.ts @@ -1,9 +1,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { stringifyUnknown } from "./ast.js"; import { normalizePath, normalizeResolutionHints } from "./paths.js"; -import { listProjectFiles } from "./projectFiles.js"; import { DEFAULT_RESOLUTION_EXTENSIONS, listResolutionCandidates } from "./resolutionCandidates.js"; import { clearWorkspaceCaches, @@ -15,22 +13,15 @@ import { } from "./workspace.js"; import { clearJvmResolutionCaches, resolveJavaImportPath, resolveKotlinImportPath } from "./resolution/jvm.js"; import { resolveGoImportPath } from "./resolution/go.js"; -import { findNearestFile } from "./resolution/files.js"; import { resolveFromNodeModules } from "./resolution/node.js"; +import { clearPhpResolutionCaches, getPhpComposerImplicitFiles, resolvePhpImportPath } from "./resolution/php.js"; import { clearPythonResolutionCache, resolvePythonModule } from "./resolution/python.js"; import { clearTsconfigCache, loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; export { resolveGoImportPath } from "./resolution/go.js"; export { resolveJvmPackageImportPaths } from "./resolution/jvm.js"; +export { getPhpComposerImplicitFiles } from "./resolution/php.js"; export { resolvePythonModule } from "./resolution/python.js"; export { loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; -import { - addProjectSymbolFile, - getOrCreateProjectSymbolIndex, - listProjectLanguageFiles, - sortProjectSymbolIndex, - type LanguageProjectSymbolIndex, -} from "./resolution/projectSymbols.js"; - export { listResolutionCandidates } from "./resolutionCandidates.js"; const resolveSpecifierCache = new Map(); @@ -144,768 +135,6 @@ export async function resolvePathLikeModule( return null; } -type PhpSymbolKind = "class" | "function" | "const"; - -type PhpPackageSymbolIndexEntry = { - packageName: string; - symbols: Set; - kindsBySymbol: Map>; -}; - -type PhpSymbolIndexEntry = { - packageName: string | null; - symbols: Set; - kindsBySymbol: Map>; - packageEntries: PhpPackageSymbolIndexEntry[]; -}; - -type PhpComposerConfig = { - psr4: Map; - psr0: Map; - classmap: string[]; - excludeFromClassmap: string[]; - files: string[]; -}; - -const phpImportResolutionCache = new Map(); -const phpSymbolIndexCache = new Map(); -const phpProjectSymbolIndexCache = new Map>(); -const phpComposerConfigCache = new Map>(); -const phpComposerAutoloadFileCache = new Map>>(); - -async function getPhpProjectSymbolIndex(projectRoot: string): Promise { - return await getOrCreateProjectSymbolIndex(phpProjectSymbolIndexCache, projectRoot, async () => { - const files = await listProjectLanguageFiles(projectRoot, ["**/*.php"]); - const index: LanguageProjectSymbolIndex = { - files, - filesByPackage: new Map(), - filesByPackageSymbol: new Map>(), - }; - - const indexEntries = await mapLimit(files, 8, async (filePath) => { - try { - const entry = await readPhpSymbolIndex(filePath); - return { filePath, entry }; - } catch { - return null; - } - }); - - for (const indexEntry of indexEntries) { - if (!indexEntry) continue; - for (const packageEntry of indexEntry.entry.packageEntries) { - addProjectSymbolFile(index, packageEntry.packageName, indexEntry.filePath, packageEntry.symbols); - } - } - - sortProjectSymbolIndex(index); - return index; - }); -} - -async function readPhpSymbolIndex(filePath: string): Promise { - const cached = phpSymbolIndexCache.get(filePath); - if (cached) return cached; - - const source = await fsp.readFile(filePath, "utf8"); - const packageEntries = extractPhpTopLevelPackageEntries(source); - const primaryEntry = packageEntries[0] ?? { - packageName: "", - symbols: new Set(), - kindsBySymbol: new Map>(), - }; - const symbols = new Set(); - const kindsBySymbol = new Map>(); - const addSymbol = (symbolName: string, symbolKind: PhpSymbolKind): void => { - symbols.add(symbolName); - const currentKinds = kindsBySymbol.get(symbolName) ?? new Set(); - currentKinds.add(symbolKind); - kindsBySymbol.set(symbolName, currentKinds); - }; - for (const packageEntry of packageEntries) { - for (const symbolName of packageEntry.symbols) { - const symbolKinds = packageEntry.kindsBySymbol.get(symbolName); - if (!symbolKinds) continue; - for (const symbolKind of symbolKinds) { - addSymbol(symbolName, symbolKind); - } - } - } - - const entry = { - packageName: primaryEntry.packageName, - symbols, - kindsBySymbol, - packageEntries, - }; - phpSymbolIndexCache.set(filePath, entry); - return entry; -} - -type PhpScannerToken = - | { type: "word"; value: string } - | { type: "brace_open" | "brace_close" | "paren_open" | "paren_close" } - | { type: "semicolon" | "comma" | "backslash" | "ampersand" | "equals" }; - -function extractPhpTopLevelPackageEntries(source: string): PhpPackageSymbolIndexEntry[] { - const packageEntries = new Map(); - const getPackageEntry = (packageName: string): PhpPackageSymbolIndexEntry => { - const existing = packageEntries.get(packageName); - if (existing) return existing; - const entry: PhpPackageSymbolIndexEntry = { - packageName, - symbols: new Set(), - kindsBySymbol: new Map>(), - }; - packageEntries.set(packageName, entry); - return entry; - }; - const addSymbol = (packageName: string, symbolName: string, symbolKind: PhpSymbolKind): void => { - const entry = getPackageEntry(packageName); - entry.symbols.add(symbolName); - const symbolKinds = entry.kindsBySymbol.get(symbolName) ?? new Set(); - symbolKinds.add(symbolKind); - entry.kindsBySymbol.set(symbolName, symbolKinds); - }; - const tokens = tokenizePhpSource(source); - let braceDepth = 0; - const namespaceBlockDepths: Array<{ packageName: string; depth: number }> = []; - const classLikeDepths: number[] = []; - const functionLikeDepths: number[] = []; - let activeNamespace = ""; - let pendingBlock: { type: "class" | "function" } | null = null; - - const inDeclarationBody = (): boolean => !!(classLikeDepths.length || functionLikeDepths.length); - const currentNamespace = (): string => - namespaceBlockDepths[namespaceBlockDepths.length - 1]?.packageName ?? activeNamespace; - - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index]; - if (!token) continue; - - if (token.type === "brace_open") { - braceDepth += 1; - if (pendingBlock?.type === "class") { - classLikeDepths.push(braceDepth); - } else if (pendingBlock?.type === "function") { - functionLikeDepths.push(braceDepth); - } - pendingBlock = null; - continue; - } - - if (token.type === "brace_close") { - if (classLikeDepths[classLikeDepths.length - 1] === braceDepth) { - classLikeDepths.pop(); - } - if (functionLikeDepths[functionLikeDepths.length - 1] === braceDepth) { - functionLikeDepths.pop(); - } - if (namespaceBlockDepths[namespaceBlockDepths.length - 1]?.depth === braceDepth) { - namespaceBlockDepths.pop(); - } - braceDepth = Math.max(0, braceDepth - 1); - pendingBlock = null; - continue; - } - - if (token.type === "semicolon") { - pendingBlock = null; - continue; - } - - if (token.type !== "word") { - continue; - } - - if (token.value === "namespace" && !inDeclarationBody()) { - let packageName = ""; - let lookahead = index + 1; - while (lookahead < tokens.length) { - const nextToken = tokens[lookahead]; - if (!nextToken) break; - if (nextToken.type === "word") { - packageName += nextToken.value; - lookahead += 1; - continue; - } - if (nextToken.type === "backslash") { - packageName += "\\"; - lookahead += 1; - continue; - } - if (nextToken.type === "brace_open") { - braceDepth += 1; - namespaceBlockDepths.push({ packageName, depth: braceDepth }); - index = lookahead; - break; - } - if (nextToken.type === "semicolon") { - activeNamespace = packageName; - index = lookahead; - break; - } - lookahead += 1; - } - continue; - } - - if ( - (token.value === "class" || token.value === "interface" || token.value === "trait" || token.value === "enum") && - !inDeclarationBody() - ) { - let lookahead = index + 1; - let symbolName: string | null = null; - while (lookahead < tokens.length) { - const nextToken = tokens[lookahead]; - if (!nextToken) break; - if (nextToken.type === "word") { - symbolName = nextToken.value; - break; - } - if (nextToken.type === "brace_open" || nextToken.type === "semicolon") { - break; - } - lookahead += 1; - } - if (symbolName) { - addSymbol(currentNamespace(), symbolName, "class"); - } - pendingBlock = { type: "class" }; - continue; - } - - if (token.value === "function" && !inDeclarationBody()) { - let lookahead = index + 1; - if (tokens[lookahead]?.type === "ampersand") { - lookahead += 1; - } - const nextToken = tokens[lookahead]; - if (nextToken?.type === "word") { - addSymbol(currentNamespace(), nextToken.value, "function"); - } - pendingBlock = { type: "function" }; - continue; - } - - if (token.value === "const" && !inDeclarationBody()) { - let lookahead = index + 1; - let expectingName = true; - while (lookahead < tokens.length) { - const nextToken = tokens[lookahead]; - if (!nextToken || nextToken.type === "semicolon") { - index = lookahead; - break; - } - if (nextToken.type === "comma") { - expectingName = true; - lookahead += 1; - continue; - } - if (nextToken.type === "equals") { - expectingName = false; - lookahead += 1; - continue; - } - if (nextToken.type === "word" && expectingName) { - addSymbol(currentNamespace(), nextToken.value, "const"); - expectingName = false; - lookahead += 1; - continue; - } - lookahead += 1; - } - } - } - - if (packageEntries.size === 0) { - packageEntries.set("", { - packageName: "", - symbols: new Set(), - kindsBySymbol: new Map>(), - }); - } - - return Array.from(packageEntries.values()).sort((left, right) => left.packageName.localeCompare(right.packageName)); -} - -function tokenizePhpSource(source: string): PhpScannerToken[] { - const tokens: PhpScannerToken[] = []; - - for (let index = 0; index < source.length; index += 1) { - const ch = source[index] ?? ""; - const next = source[index + 1] ?? ""; - - if (/\s/.test(ch)) continue; - - if (ch === "/" && next === "/") { - index += 2; - while (index < source.length && source[index] !== "\n") index += 1; - continue; - } - if (ch === "/" && next === "*") { - index += 2; - while (index < source.length - 1 && !(source[index] === "*" && source[index + 1] === "/")) { - index += 1; - } - index += 1; - continue; - } - if (ch === "#" && next === "[") { - index += 2; - let depth = 1; - while (index < source.length && depth > 0) { - const current = source[index] ?? ""; - const afterCurrent = source[index + 1] ?? ""; - if (current === "'" || current === '"') { - const quote = current; - index += 1; - while (index < source.length) { - if (source[index] === "\\") { - index += 2; - continue; - } - if (source[index] === quote) break; - index += 1; - } - index += 1; - continue; - } - if (current === "/" && afterCurrent === "*") { - index += 2; - while (index < source.length - 1 && !(source[index] === "*" && source[index + 1] === "/")) { - index += 1; - } - index += 2; - continue; - } - if (current === "[" || current === "(" || current === "{") { - depth += 1; - index += 1; - continue; - } - if (current === "]" || current === ")" || current === "}") { - depth -= 1; - index += 1; - continue; - } - index += 1; - } - index -= 1; - continue; - } - if (ch === "#") { - index += 1; - while (index < source.length && source[index] !== "\n") index += 1; - continue; - } - if (ch === "'" || ch === '"') { - const quote = ch; - index += 1; - while (index < source.length) { - if (source[index] === "\\") { - index += 2; - continue; - } - if (source[index] === quote) break; - index += 1; - } - continue; - } - - if (/[A-Za-z_]/.test(ch)) { - let end = index + 1; - while (end < source.length && /[A-Za-z0-9_]/.test(source[end] ?? "")) { - end += 1; - } - tokens.push({ type: "word", value: source.slice(index, end) }); - index = end - 1; - continue; - } - - if (ch === "{") { - tokens.push({ type: "brace_open" }); - continue; - } - if (ch === "}") { - tokens.push({ type: "brace_close" }); - continue; - } - if (ch === "(") { - tokens.push({ type: "paren_open" }); - continue; - } - if (ch === ")") { - tokens.push({ type: "paren_close" }); - continue; - } - if (ch === ";") { - tokens.push({ type: "semicolon" }); - continue; - } - if (ch === ",") { - tokens.push({ type: "comma" }); - continue; - } - if (ch === "\\") { - tokens.push({ type: "backslash" }); - continue; - } - if (ch === "&") { - tokens.push({ type: "ampersand" }); - continue; - } - if (ch === "=") { - tokens.push({ type: "equals" }); - } - } - - return tokens; -} - -function readComposerNamespaceDirs(value: unknown, composerDir: string): Map { - const result = new Map(); - if (!value || typeof value !== "object") { - return result; - } - for (const [prefix, rawTarget] of Object.entries(value as Record)) { - const targets = Array.isArray(rawTarget) ? rawTarget : [rawTarget]; - const dirs = targets - .filter((target): target is string => typeof target === "string") - .map((target) => resolveComposerPath(target, composerDir)); - if (dirs.length) { - result.set(prefix, dirs); - } - } - return result; -} - -function mergeComposerNamespaceDirMaps(...maps: Map[]): Map { - const merged = new Map(); - for (const map of maps) { - for (const [prefix, dirs] of map) { - const currentDirs = merged.get(prefix) ?? []; - const dedupedDirs = Array.from(new Set([...currentDirs, ...dirs])); - merged.set(prefix, dedupedDirs); - } - } - return merged; -} - -function resolveComposerPath(entry: string, composerDir: string): string { - if (entry.startsWith("/") || entry.startsWith("\\")) { - return path.resolve(composerDir, `.${entry}`); - } - if (/^[A-Za-z]:[\\/]/.test(entry) || path.isAbsolute(entry)) { - return path.resolve(entry); - } - return path.resolve(composerDir, entry); -} - -function readComposerStringList(value: unknown, composerDir: string): string[] { - if (!Array.isArray(value)) return []; - return value - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => resolveComposerPath(entry, composerDir)); -} - -async function loadPhpComposerConfig(composerPath: string): Promise { - const cached = phpComposerConfigCache.get(composerPath); - if (cached) return await cached; - - const pending = (async () => { - try { - const raw = await fsp.readFile(composerPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - const composerDir = path.dirname(composerPath); - const autoload = - parsed.autoload && typeof parsed.autoload === "object" ? (parsed.autoload as Record) : {}; - const autoloadDev = - parsed["autoload-dev"] && typeof parsed["autoload-dev"] === "object" - ? (parsed["autoload-dev"] as Record) - : {}; - - const psr4 = mergeComposerNamespaceDirMaps( - readComposerNamespaceDirs(autoload["psr-4"], composerDir), - readComposerNamespaceDirs(autoloadDev["psr-4"], composerDir), - ); - const psr0 = mergeComposerNamespaceDirMaps( - readComposerNamespaceDirs(autoload["psr-0"], composerDir), - readComposerNamespaceDirs(autoloadDev["psr-0"], composerDir), - ); - const classmap = [ - ...readComposerStringList(autoload["classmap"], composerDir), - ...readComposerStringList(autoloadDev["classmap"], composerDir), - ]; - const excludeFromClassmap = [ - ...readComposerStringList(autoload["exclude-from-classmap"], composerDir), - ...readComposerStringList(autoloadDev["exclude-from-classmap"], composerDir), - ]; - const files = [ - ...readComposerStringList(autoload["files"], composerDir), - ...readComposerStringList(autoloadDev["files"], composerDir), - ]; - - return { psr4, psr0, classmap, excludeFromClassmap, files }; - } catch { - return null; - } - })(); - - phpComposerConfigCache.set(composerPath, pending); - return await pending; -} - -function sortPhpComposerMappings(mappings: Map): Array<[string, string[]]> { - return Array.from(mappings.entries()).sort((left, right) => right[0].length - left[0].length); -} - -async function resolvePhpPsr4MappedPath(spec: string, mappings: Map): Promise { - const normalizedSpec = spec.replace(/^\\+/, ""); - const mappingEntries = sortPhpComposerMappings(mappings); - - for (const [prefix, dirs] of mappingEntries) { - if (!normalizedSpec.startsWith(prefix)) continue; - const suffix = normalizedSpec.slice(prefix.length).replace(/\\/g, "/"); - for (const dir of dirs) { - const basePath = suffix ? path.join(dir, suffix) : dir; - const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); - if (resolved) return resolved; - } - } - - return null; -} - -function buildPhpPsr0RelativePath(spec: string, prefix: string): string | null { - if (!spec.startsWith(prefix)) return null; - const suffix = spec.slice(prefix.length); - const namespaceParts = suffix.split("\\"); - const classPart = namespaceParts.pop() ?? ""; - const namespacePath = namespaceParts.filter(Boolean).join("/"); - const classPath = classPart.replace(/_/g, "/"); - return [namespacePath, classPath].filter(Boolean).join("/"); -} - -async function resolvePhpPsr0MappedPath(spec: string, mappings: Map): Promise { - const normalizedSpec = spec.replace(/^\\+/, ""); - const mappingEntries = sortPhpComposerMappings(mappings); - - for (const [prefix, dirs] of mappingEntries) { - const relativePath = buildPhpPsr0RelativePath(normalizedSpec, prefix); - if (relativePath === null) continue; - for (const dir of dirs) { - const basePath = relativePath ? path.join(dir, relativePath) : dir; - const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); - if (resolved) return resolved; - } - } - - return null; -} - -async function resolvePhpSymbolImportPath( - projectRoot: string, - spec: string, - preferredKind?: "class" | "function" | "const", - allowedFiles?: Set, -): Promise { - const normalizedSpec = spec.replace(/^\\+/, ""); - const projectIndex = await getPhpProjectSymbolIndex(projectRoot); - const pickCandidate = async (candidates: string[], symbolName?: string): Promise => { - for (const candidate of candidates) { - const resolvedCandidate = path.resolve(candidate); - if (allowedFiles && !allowedFiles.has(resolvedCandidate)) { - continue; - } - if (!symbolName || !preferredKind) { - return resolvedCandidate; - } - const entry = await readPhpSymbolIndex(resolvedCandidate); - const symbolKinds = entry.kindsBySymbol.get(symbolName); - if (symbolKinds?.has(preferredKind)) { - return resolvedCandidate; - } - } - return null; - }; - - const exactNamespaceFiles = projectIndex.filesByPackage.get(normalizedSpec) ?? []; - const exactNamespaceHit = await pickCandidate(exactNamespaceFiles); - if (exactNamespaceHit) { - return exactNamespaceHit; - } - - const parts = normalizedSpec.split("\\").filter(Boolean); - if (parts.length === 1) { - const globalFiles = projectIndex.filesByPackageSymbol.get("")?.get(parts[0]!) ?? []; - return await pickCandidate(globalFiles, parts[0]); - } - - if (parts.length < 2) { - return null; - } - - const importedName = parts[parts.length - 1]!; - const packageName = parts.slice(0, -1).join("\\"); - const symbolFiles = projectIndex.filesByPackageSymbol.get(packageName)?.get(importedName) ?? []; - const symbolHit = await pickCandidate(symbolFiles, importedName); - if (symbolHit) { - return symbolHit; - } - - const packageFiles = projectIndex.filesByPackage.get(packageName) ?? []; - return await pickCandidate(packageFiles, importedName); -} - -async function findPhpComposerPath(projectRoot: string, fromFile: string): Promise { - return ( - (await findNearestFile(path.dirname(fromFile), projectRoot, "composer.json")) ?? - ((await fileExists(path.join(projectRoot, "composer.json"))) ? path.join(projectRoot, "composer.json") : null) - ); -} - -export async function getPhpComposerImplicitFiles(projectRoot: string, fromFile: string): Promise { - const composerPath = await findPhpComposerPath(projectRoot, fromFile); - if (!composerPath) { - return []; - } - - const composerConfig = await loadPhpComposerConfig(composerPath); - if (!composerConfig) { - return []; - } - - const deduped = new Set(); - for (const filePath of composerConfig.files) { - if (!(await fileExists(filePath))) continue; - deduped.add(path.resolve(filePath)); - } - return Array.from(deduped); -} - -async function getPhpComposerAutoloadFiles( - composerPath: string, - composerConfig: PhpComposerConfig, -): Promise> { - const cached = phpComposerAutoloadFileCache.get(composerPath); - if (cached) { - return await cached; - } - - const pending = (async () => { - const candidates = new Set(); - const roots = new Set([ - ...composerConfig.classmap, - ...composerConfig.files, - ...Array.from(composerConfig.psr4.values()).flat(), - ...Array.from(composerConfig.psr0.values()).flat(), - ]); - - for (const root of roots) { - try { - const stat = await fsp.stat(root); - if (stat.isDirectory()) { - const files = await listProjectFiles(root, ["**/*.php"]); - for (const filePath of files) { - if (isPhpComposerClassmapExcluded(filePath, composerConfig)) { - continue; - } - candidates.add(path.resolve(filePath)); - } - continue; - } - if (stat.isFile() && root.toLowerCase().endsWith(".php")) { - if (isPhpComposerClassmapExcluded(root, composerConfig)) continue; - candidates.add(path.resolve(root)); - } - } catch { - // Ignore missing Composer autoload roots. - } - } - - return candidates; - })(); - - phpComposerAutoloadFileCache.set(composerPath, pending); - return await pending; -} - -function isPhpComposerClassmapExcluded(filePath: string, composerConfig: PhpComposerConfig): boolean { - const normalizedFile = normalizePath(path.resolve(filePath)); - return composerConfig.excludeFromClassmap.some((entry) => { - const normalizedEntry = normalizePath(path.resolve(entry)).replace(/\/+$/, ""); - return normalizedFile === normalizedEntry || normalizedFile.startsWith(`${normalizedEntry}/`); - }); -} - -async function resolvePhpImportPath( - projectRoot: string, - fromFile: string, - spec: string, - preferredKind?: "class" | "function" | "const", -): Promise { - const cacheKey = `${projectRoot}::${fromFile}::${spec}::${preferredKind ?? "any"}`; - const cached = phpImportResolutionCache.get(cacheKey); - if (cached !== undefined) return cached; - - const normalizedSpec = spec.trim(); - const isPathLike = - normalizedSpec.startsWith(".") || normalizedSpec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalizedSpec); - if (isPathLike) { - const resolved = await resolveSpecifier(fromFile, normalizedSpec, projectRoot, undefined, undefined, { - resolutionExtensions: [".php"], - }); - const fileResolved = typeof resolved === "string" ? resolved : null; - phpImportResolutionCache.set(cacheKey, fileResolved); - return fileResolved; - } - - const composerPath = await findPhpComposerPath(projectRoot, fromFile); - if (composerPath) { - const composerConfig = await loadPhpComposerConfig(composerPath); - if (composerConfig) { - if (!preferredKind || preferredKind === "class") { - const psr4Resolved = await resolvePhpPsr4MappedPath(normalizedSpec, composerConfig.psr4); - if (psr4Resolved) { - phpImportResolutionCache.set(cacheKey, psr4Resolved); - return psr4Resolved; - } - const psr0Resolved = await resolvePhpPsr0MappedPath(normalizedSpec, composerConfig.psr0); - if (psr0Resolved) { - phpImportResolutionCache.set(cacheKey, psr0Resolved); - return psr0Resolved; - } - } - - const autoloadFiles = await getPhpComposerAutoloadFiles(composerPath, composerConfig); - const symbolResolved = await resolvePhpSymbolImportPath( - projectRoot, - normalizedSpec, - preferredKind, - autoloadFiles, - ); - if (symbolResolved && !isPhpComposerClassmapExcluded(symbolResolved, composerConfig)) { - phpImportResolutionCache.set(cacheKey, symbolResolved); - return symbolResolved; - } - - phpImportResolutionCache.set(cacheKey, null); - return null; - } - } - - const symbolResolved = await resolvePhpSymbolImportPath(projectRoot, normalizedSpec, preferredKind); - if (symbolResolved) { - phpImportResolutionCache.set(cacheKey, symbolResolved); - return symbolResolved; - } - - const pathLikeResolved = await resolvePathLikeModule(projectRoot, normalizedSpec.replace(/\\/g, "/"), [".php"]); - phpImportResolutionCache.set(cacheKey, pathLikeResolved); - return pathLikeResolved; -} - export async function resolveImportSpecifier( projectRoot: string, fromFile: string, @@ -1084,11 +313,7 @@ export function clearImportResolutionCaches(): void { clearPythonResolutionCache(); clearFileExistsCache(); clearJvmResolutionCaches(); - phpImportResolutionCache.clear(); - phpSymbolIndexCache.clear(); - phpProjectSymbolIndexCache.clear(); - phpComposerConfigCache.clear(); - phpComposerAutoloadFileCache.clear(); + clearPhpResolutionCaches(); } export function clearResolutionCaches(): void { diff --git a/src/util/resolution/php.ts b/src/util/resolution/php.ts new file mode 100644 index 00000000..e48d5f7c --- /dev/null +++ b/src/util/resolution/php.ts @@ -0,0 +1,822 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { stringifyUnknown } from "../ast.js"; +import { normalizePath } from "../paths.js"; +import { listProjectFiles } from "../projectFiles.js"; +import { listResolutionCandidates } from "../resolutionCandidates.js"; +import { mapLimitSemaphore } from "../semaphore.js"; +import { fileExists } from "../workspace.js"; +import { findNearestFile } from "./files.js"; +import { + addProjectSymbolFile, + getOrCreateProjectSymbolIndex, + listProjectLanguageFiles, + sortProjectSymbolIndex, + type LanguageProjectSymbolIndex, +} from "./projectSymbols.js"; + +type FileId = string; + +async function findFirstExistingResolutionCandidate( + base: string, + resolutionExtensions?: readonly string[], +): Promise { + for (const candidate of listResolutionCandidates(base, resolutionExtensions)) { + if (await fileExists(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +async function resolvePhpPathLikeSpecifier( + projectRoot: string, + fromFile: string, + spec: string, +): Promise { + let base = path.resolve(path.dirname(fromFile), spec); + if (/^[A-Za-z]:[\\/]/.test(spec)) { + base = spec; + } else if (spec.startsWith("/")) { + base = path.join(projectRoot, spec); + } + return await findFirstExistingResolutionCandidate(base, [".php"]); +} + +async function resolvePathLikePhpModule(projectRoot: string, spec: string): Promise { + const parts = spec.split(/[/.:]+/).filter(Boolean); + for (let i = parts.length; i > 0; i--) { + const sub = parts.slice(0, i); + const basePath = path.join(projectRoot, ...sub); + const fileHit = await findFirstExistingResolutionCandidate(basePath, [".php"]); + if (fileHit) return fileHit; + } + return null; +} + +type PhpSymbolKind = "class" | "function" | "const"; + +type PhpPackageSymbolIndexEntry = { + packageName: string; + symbols: Set; + kindsBySymbol: Map>; +}; + +type PhpSymbolIndexEntry = { + packageName: string | null; + symbols: Set; + kindsBySymbol: Map>; + packageEntries: PhpPackageSymbolIndexEntry[]; +}; + +type PhpComposerConfig = { + psr4: Map; + psr0: Map; + classmap: string[]; + excludeFromClassmap: string[]; + files: string[]; +}; + +const phpImportResolutionCache = new Map(); +const phpSymbolIndexCache = new Map(); +const phpProjectSymbolIndexCache = new Map>(); +const phpComposerConfigCache = new Map>(); +const phpComposerAutoloadFileCache = new Map>>(); + +async function getPhpProjectSymbolIndex(projectRoot: string): Promise { + return await getOrCreateProjectSymbolIndex(phpProjectSymbolIndexCache, projectRoot, async () => { + const files = await listProjectLanguageFiles(projectRoot, ["**/*.php"]); + const index: LanguageProjectSymbolIndex = { + files, + filesByPackage: new Map(), + filesByPackageSymbol: new Map>(), + }; + + const indexEntries = await mapLimitSemaphore(files, 8, async (filePath) => { + try { + const entry = await readPhpSymbolIndex(filePath); + return { filePath, entry }; + } catch { + return null; + } + }); + + for (const indexEntry of indexEntries) { + if (!indexEntry) continue; + for (const packageEntry of indexEntry.entry.packageEntries) { + addProjectSymbolFile(index, packageEntry.packageName, indexEntry.filePath, packageEntry.symbols); + } + } + + sortProjectSymbolIndex(index); + return index; + }); +} + +async function readPhpSymbolIndex(filePath: string): Promise { + const cached = phpSymbolIndexCache.get(filePath); + if (cached) return cached; + + const source = await fsp.readFile(filePath, "utf8"); + const packageEntries = extractPhpTopLevelPackageEntries(source); + const primaryEntry = packageEntries[0] ?? { + packageName: "", + symbols: new Set(), + kindsBySymbol: new Map>(), + }; + const symbols = new Set(); + const kindsBySymbol = new Map>(); + const addSymbol = (symbolName: string, symbolKind: PhpSymbolKind): void => { + symbols.add(symbolName); + const currentKinds = kindsBySymbol.get(symbolName) ?? new Set(); + currentKinds.add(symbolKind); + kindsBySymbol.set(symbolName, currentKinds); + }; + for (const packageEntry of packageEntries) { + for (const symbolName of packageEntry.symbols) { + const symbolKinds = packageEntry.kindsBySymbol.get(symbolName); + if (!symbolKinds) continue; + for (const symbolKind of symbolKinds) { + addSymbol(symbolName, symbolKind); + } + } + } + + const entry = { + packageName: primaryEntry.packageName, + symbols, + kindsBySymbol, + packageEntries, + }; + phpSymbolIndexCache.set(filePath, entry); + return entry; +} + +type PhpScannerToken = + | { type: "word"; value: string } + | { type: "brace_open" | "brace_close" | "paren_open" | "paren_close" } + | { type: "semicolon" | "comma" | "backslash" | "ampersand" | "equals" }; + +function extractPhpTopLevelPackageEntries(source: string): PhpPackageSymbolIndexEntry[] { + const packageEntries = new Map(); + const getPackageEntry = (packageName: string): PhpPackageSymbolIndexEntry => { + const existing = packageEntries.get(packageName); + if (existing) return existing; + const entry: PhpPackageSymbolIndexEntry = { + packageName, + symbols: new Set(), + kindsBySymbol: new Map>(), + }; + packageEntries.set(packageName, entry); + return entry; + }; + const addSymbol = (packageName: string, symbolName: string, symbolKind: PhpSymbolKind): void => { + const entry = getPackageEntry(packageName); + entry.symbols.add(symbolName); + const symbolKinds = entry.kindsBySymbol.get(symbolName) ?? new Set(); + symbolKinds.add(symbolKind); + entry.kindsBySymbol.set(symbolName, symbolKinds); + }; + const tokens = tokenizePhpSource(source); + let braceDepth = 0; + const namespaceBlockDepths: Array<{ packageName: string; depth: number }> = []; + const classLikeDepths: number[] = []; + const functionLikeDepths: number[] = []; + let activeNamespace = ""; + let pendingBlock: { type: "class" | "function" } | null = null; + + const inDeclarationBody = (): boolean => !!(classLikeDepths.length || functionLikeDepths.length); + const currentNamespace = (): string => + namespaceBlockDepths[namespaceBlockDepths.length - 1]?.packageName ?? activeNamespace; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) continue; + + if (token.type === "brace_open") { + braceDepth += 1; + if (pendingBlock?.type === "class") { + classLikeDepths.push(braceDepth); + } else if (pendingBlock?.type === "function") { + functionLikeDepths.push(braceDepth); + } + pendingBlock = null; + continue; + } + + if (token.type === "brace_close") { + if (classLikeDepths[classLikeDepths.length - 1] === braceDepth) { + classLikeDepths.pop(); + } + if (functionLikeDepths[functionLikeDepths.length - 1] === braceDepth) { + functionLikeDepths.pop(); + } + if (namespaceBlockDepths[namespaceBlockDepths.length - 1]?.depth === braceDepth) { + namespaceBlockDepths.pop(); + } + braceDepth = Math.max(0, braceDepth - 1); + pendingBlock = null; + continue; + } + + if (token.type === "semicolon") { + pendingBlock = null; + continue; + } + + if (token.type !== "word") { + continue; + } + + if (token.value === "namespace" && !inDeclarationBody()) { + let packageName = ""; + let lookahead = index + 1; + while (lookahead < tokens.length) { + const nextToken = tokens[lookahead]; + if (!nextToken) break; + if (nextToken.type === "word") { + packageName += nextToken.value; + lookahead += 1; + continue; + } + if (nextToken.type === "backslash") { + packageName += "\\"; + lookahead += 1; + continue; + } + if (nextToken.type === "brace_open") { + braceDepth += 1; + namespaceBlockDepths.push({ packageName, depth: braceDepth }); + index = lookahead; + break; + } + if (nextToken.type === "semicolon") { + activeNamespace = packageName; + index = lookahead; + break; + } + lookahead += 1; + } + continue; + } + + if ( + (token.value === "class" || token.value === "interface" || token.value === "trait" || token.value === "enum") && + !inDeclarationBody() + ) { + let lookahead = index + 1; + let symbolName: string | null = null; + while (lookahead < tokens.length) { + const nextToken = tokens[lookahead]; + if (!nextToken) break; + if (nextToken.type === "word") { + symbolName = nextToken.value; + break; + } + if (nextToken.type === "brace_open" || nextToken.type === "semicolon") { + break; + } + lookahead += 1; + } + if (symbolName) { + addSymbol(currentNamespace(), symbolName, "class"); + } + pendingBlock = { type: "class" }; + continue; + } + + if (token.value === "function" && !inDeclarationBody()) { + let lookahead = index + 1; + if (tokens[lookahead]?.type === "ampersand") { + lookahead += 1; + } + const nextToken = tokens[lookahead]; + if (nextToken?.type === "word") { + addSymbol(currentNamespace(), nextToken.value, "function"); + } + pendingBlock = { type: "function" }; + continue; + } + + if (token.value === "const" && !inDeclarationBody()) { + let lookahead = index + 1; + let expectingName = true; + while (lookahead < tokens.length) { + const nextToken = tokens[lookahead]; + if (!nextToken || nextToken.type === "semicolon") { + index = lookahead; + break; + } + if (nextToken.type === "comma") { + expectingName = true; + lookahead += 1; + continue; + } + if (nextToken.type === "equals") { + expectingName = false; + lookahead += 1; + continue; + } + if (nextToken.type === "word" && expectingName) { + addSymbol(currentNamespace(), nextToken.value, "const"); + expectingName = false; + lookahead += 1; + continue; + } + lookahead += 1; + } + } + } + + if (packageEntries.size === 0) { + packageEntries.set("", { + packageName: "", + symbols: new Set(), + kindsBySymbol: new Map>(), + }); + } + + return Array.from(packageEntries.values()).sort((left, right) => left.packageName.localeCompare(right.packageName)); +} + +function tokenizePhpSource(source: string): PhpScannerToken[] { + const tokens: PhpScannerToken[] = []; + + for (let index = 0; index < source.length; index += 1) { + const ch = source[index] ?? ""; + const next = source[index + 1] ?? ""; + + if (/\s/.test(ch)) continue; + + if (ch === "/" && next === "/") { + index += 2; + while (index < source.length && source[index] !== "\n") index += 1; + continue; + } + if (ch === "/" && next === "*") { + index += 2; + while (index < source.length - 1 && !(source[index] === "*" && source[index + 1] === "/")) { + index += 1; + } + index += 1; + continue; + } + if (ch === "#" && next === "[") { + index += 2; + let depth = 1; + while (index < source.length && depth > 0) { + const current = source[index] ?? ""; + const afterCurrent = source[index + 1] ?? ""; + if (current === "'" || current === '"') { + const quote = current; + index += 1; + while (index < source.length) { + if (source[index] === "\\") { + index += 2; + continue; + } + if (source[index] === quote) break; + index += 1; + } + index += 1; + continue; + } + if (current === "/" && afterCurrent === "*") { + index += 2; + while (index < source.length - 1 && !(source[index] === "*" && source[index + 1] === "/")) { + index += 1; + } + index += 2; + continue; + } + if (current === "[" || current === "(" || current === "{") { + depth += 1; + index += 1; + continue; + } + if (current === "]" || current === ")" || current === "}") { + depth -= 1; + index += 1; + continue; + } + index += 1; + } + index -= 1; + continue; + } + if (ch === "#") { + index += 1; + while (index < source.length && source[index] !== "\n") index += 1; + continue; + } + if (ch === "'" || ch === '"') { + const quote = ch; + index += 1; + while (index < source.length) { + if (source[index] === "\\") { + index += 2; + continue; + } + if (source[index] === quote) break; + index += 1; + } + continue; + } + + if (/[A-Za-z_]/.test(ch)) { + let end = index + 1; + while (end < source.length && /[A-Za-z0-9_]/.test(source[end] ?? "")) { + end += 1; + } + tokens.push({ type: "word", value: source.slice(index, end) }); + index = end - 1; + continue; + } + + if (ch === "{") { + tokens.push({ type: "brace_open" }); + continue; + } + if (ch === "}") { + tokens.push({ type: "brace_close" }); + continue; + } + if (ch === "(") { + tokens.push({ type: "paren_open" }); + continue; + } + if (ch === ")") { + tokens.push({ type: "paren_close" }); + continue; + } + if (ch === ";") { + tokens.push({ type: "semicolon" }); + continue; + } + if (ch === ",") { + tokens.push({ type: "comma" }); + continue; + } + if (ch === "\\") { + tokens.push({ type: "backslash" }); + continue; + } + if (ch === "&") { + tokens.push({ type: "ampersand" }); + continue; + } + if (ch === "=") { + tokens.push({ type: "equals" }); + } + } + + return tokens; +} + +function readComposerNamespaceDirs(value: unknown, composerDir: string): Map { + const result = new Map(); + if (!value || typeof value !== "object") { + return result; + } + for (const [prefix, rawTarget] of Object.entries(value as Record)) { + const targets = Array.isArray(rawTarget) ? rawTarget : [rawTarget]; + const dirs = targets + .filter((target): target is string => typeof target === "string") + .map((target) => resolveComposerPath(target, composerDir)); + if (dirs.length) { + result.set(prefix, dirs); + } + } + return result; +} + +function mergeComposerNamespaceDirMaps(...maps: Map[]): Map { + const merged = new Map(); + for (const map of maps) { + for (const [prefix, dirs] of map) { + const currentDirs = merged.get(prefix) ?? []; + const dedupedDirs = Array.from(new Set([...currentDirs, ...dirs])); + merged.set(prefix, dedupedDirs); + } + } + return merged; +} + +function resolveComposerPath(entry: string, composerDir: string): string { + if (entry.startsWith("/") || entry.startsWith("\\")) { + return path.resolve(composerDir, `.${entry}`); + } + if (/^[A-Za-z]:[\\/]/.test(entry) || path.isAbsolute(entry)) { + return path.resolve(entry); + } + return path.resolve(composerDir, entry); +} + +function readComposerStringList(value: unknown, composerDir: string): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => resolveComposerPath(entry, composerDir)); +} + +async function loadPhpComposerConfig(composerPath: string): Promise { + const cached = phpComposerConfigCache.get(composerPath); + if (cached) return await cached; + + const pending = (async () => { + try { + const raw = await fsp.readFile(composerPath, "utf8"); + const parsed = JSON.parse(raw) as Record; + const composerDir = path.dirname(composerPath); + const autoload = + parsed.autoload && typeof parsed.autoload === "object" ? (parsed.autoload as Record) : {}; + const autoloadDev = + parsed["autoload-dev"] && typeof parsed["autoload-dev"] === "object" + ? (parsed["autoload-dev"] as Record) + : {}; + + const psr4 = mergeComposerNamespaceDirMaps( + readComposerNamespaceDirs(autoload["psr-4"], composerDir), + readComposerNamespaceDirs(autoloadDev["psr-4"], composerDir), + ); + const psr0 = mergeComposerNamespaceDirMaps( + readComposerNamespaceDirs(autoload["psr-0"], composerDir), + readComposerNamespaceDirs(autoloadDev["psr-0"], composerDir), + ); + const classmap = [ + ...readComposerStringList(autoload["classmap"], composerDir), + ...readComposerStringList(autoloadDev["classmap"], composerDir), + ]; + const excludeFromClassmap = [ + ...readComposerStringList(autoload["exclude-from-classmap"], composerDir), + ...readComposerStringList(autoloadDev["exclude-from-classmap"], composerDir), + ]; + const files = [ + ...readComposerStringList(autoload["files"], composerDir), + ...readComposerStringList(autoloadDev["files"], composerDir), + ]; + + return { psr4, psr0, classmap, excludeFromClassmap, files }; + } catch { + return null; + } + })(); + + phpComposerConfigCache.set(composerPath, pending); + return await pending; +} + +function sortPhpComposerMappings(mappings: Map): Array<[string, string[]]> { + return Array.from(mappings.entries()).sort((left, right) => right[0].length - left[0].length); +} + +async function resolvePhpPsr4MappedPath(spec: string, mappings: Map): Promise { + const normalizedSpec = spec.replace(/^\\+/, ""); + const mappingEntries = sortPhpComposerMappings(mappings); + + for (const [prefix, dirs] of mappingEntries) { + if (!normalizedSpec.startsWith(prefix)) continue; + const suffix = normalizedSpec.slice(prefix.length).replace(/\\/g, "/"); + for (const dir of dirs) { + const basePath = suffix ? path.join(dir, suffix) : dir; + const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); + if (resolved) return resolved; + } + } + + return null; +} + +function buildPhpPsr0RelativePath(spec: string, prefix: string): string | null { + if (!spec.startsWith(prefix)) return null; + const suffix = spec.slice(prefix.length); + const namespaceParts = suffix.split("\\"); + const classPart = namespaceParts.pop() ?? ""; + const namespacePath = namespaceParts.filter(Boolean).join("/"); + const classPath = classPart.replace(/_/g, "/"); + return [namespacePath, classPath].filter(Boolean).join("/"); +} + +async function resolvePhpPsr0MappedPath(spec: string, mappings: Map): Promise { + const normalizedSpec = spec.replace(/^\\+/, ""); + const mappingEntries = sortPhpComposerMappings(mappings); + + for (const [prefix, dirs] of mappingEntries) { + const relativePath = buildPhpPsr0RelativePath(normalizedSpec, prefix); + if (relativePath === null) continue; + for (const dir of dirs) { + const basePath = relativePath ? path.join(dir, relativePath) : dir; + const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); + if (resolved) return resolved; + } + } + + return null; +} + +async function resolvePhpSymbolImportPath( + projectRoot: string, + spec: string, + preferredKind?: "class" | "function" | "const", + allowedFiles?: Set, +): Promise { + const normalizedSpec = spec.replace(/^\\+/, ""); + const projectIndex = await getPhpProjectSymbolIndex(projectRoot); + const pickCandidate = async (candidates: string[], symbolName?: string): Promise => { + for (const candidate of candidates) { + const resolvedCandidate = path.resolve(candidate); + if (allowedFiles && !allowedFiles.has(resolvedCandidate)) { + continue; + } + if (!symbolName || !preferredKind) { + return resolvedCandidate; + } + const entry = await readPhpSymbolIndex(resolvedCandidate); + const symbolKinds = entry.kindsBySymbol.get(symbolName); + if (symbolKinds?.has(preferredKind)) { + return resolvedCandidate; + } + } + return null; + }; + + const exactNamespaceFiles = projectIndex.filesByPackage.get(normalizedSpec) ?? []; + const exactNamespaceHit = await pickCandidate(exactNamespaceFiles); + if (exactNamespaceHit) { + return exactNamespaceHit; + } + + const parts = normalizedSpec.split("\\").filter(Boolean); + if (parts.length === 1) { + const globalFiles = projectIndex.filesByPackageSymbol.get("")?.get(parts[0]!) ?? []; + return await pickCandidate(globalFiles, parts[0]); + } + + if (parts.length < 2) { + return null; + } + + const importedName = parts[parts.length - 1]!; + const packageName = parts.slice(0, -1).join("\\"); + const symbolFiles = projectIndex.filesByPackageSymbol.get(packageName)?.get(importedName) ?? []; + const symbolHit = await pickCandidate(symbolFiles, importedName); + if (symbolHit) { + return symbolHit; + } + + const packageFiles = projectIndex.filesByPackage.get(packageName) ?? []; + return await pickCandidate(packageFiles, importedName); +} + +async function findPhpComposerPath(projectRoot: string, fromFile: string): Promise { + return ( + (await findNearestFile(path.dirname(fromFile), projectRoot, "composer.json")) ?? + ((await fileExists(path.join(projectRoot, "composer.json"))) ? path.join(projectRoot, "composer.json") : null) + ); +} + +export async function getPhpComposerImplicitFiles(projectRoot: string, fromFile: string): Promise { + const composerPath = await findPhpComposerPath(projectRoot, fromFile); + if (!composerPath) { + return []; + } + + const composerConfig = await loadPhpComposerConfig(composerPath); + if (!composerConfig) { + return []; + } + + const deduped = new Set(); + for (const filePath of composerConfig.files) { + if (!(await fileExists(filePath))) continue; + deduped.add(path.resolve(filePath)); + } + return Array.from(deduped); +} + +async function getPhpComposerAutoloadFiles( + composerPath: string, + composerConfig: PhpComposerConfig, +): Promise> { + const cached = phpComposerAutoloadFileCache.get(composerPath); + if (cached) { + return await cached; + } + + const pending = (async () => { + const candidates = new Set(); + const roots = new Set([ + ...composerConfig.classmap, + ...composerConfig.files, + ...Array.from(composerConfig.psr4.values()).flat(), + ...Array.from(composerConfig.psr0.values()).flat(), + ]); + + for (const root of roots) { + try { + const stat = await fsp.stat(root); + if (stat.isDirectory()) { + const files = await listProjectFiles(root, ["**/*.php"]); + for (const filePath of files) { + if (isPhpComposerClassmapExcluded(filePath, composerConfig)) { + continue; + } + candidates.add(path.resolve(filePath)); + } + continue; + } + if (stat.isFile() && root.toLowerCase().endsWith(".php")) { + if (isPhpComposerClassmapExcluded(root, composerConfig)) continue; + candidates.add(path.resolve(root)); + } + } catch { + // Ignore missing Composer autoload roots. + } + } + + return candidates; + })(); + + phpComposerAutoloadFileCache.set(composerPath, pending); + return await pending; +} + +function isPhpComposerClassmapExcluded(filePath: string, composerConfig: PhpComposerConfig): boolean { + const normalizedFile = normalizePath(path.resolve(filePath)); + return composerConfig.excludeFromClassmap.some((entry) => { + const normalizedEntry = normalizePath(path.resolve(entry)).replace(/\/+$/, ""); + return normalizedFile === normalizedEntry || normalizedFile.startsWith(`${normalizedEntry}/`); + }); +} + +export async function resolvePhpImportPath( + projectRoot: string, + fromFile: string, + spec: string, + preferredKind?: "class" | "function" | "const", +): Promise { + const cacheKey = `${projectRoot}::${fromFile}::${spec}::${preferredKind ?? "any"}`; + const cached = phpImportResolutionCache.get(cacheKey); + if (cached !== undefined) return cached; + + const normalizedSpec = spec.trim(); + const isPathLike = + normalizedSpec.startsWith(".") || normalizedSpec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalizedSpec); + if (isPathLike) { + const fileResolved = await resolvePhpPathLikeSpecifier(projectRoot, fromFile, normalizedSpec); + phpImportResolutionCache.set(cacheKey, fileResolved); + return fileResolved; + } + + const composerPath = await findPhpComposerPath(projectRoot, fromFile); + if (composerPath) { + const composerConfig = await loadPhpComposerConfig(composerPath); + if (composerConfig) { + if (!preferredKind || preferredKind === "class") { + const psr4Resolved = await resolvePhpPsr4MappedPath(normalizedSpec, composerConfig.psr4); + if (psr4Resolved) { + phpImportResolutionCache.set(cacheKey, psr4Resolved); + return psr4Resolved; + } + const psr0Resolved = await resolvePhpPsr0MappedPath(normalizedSpec, composerConfig.psr0); + if (psr0Resolved) { + phpImportResolutionCache.set(cacheKey, psr0Resolved); + return psr0Resolved; + } + } + + const autoloadFiles = await getPhpComposerAutoloadFiles(composerPath, composerConfig); + const symbolResolved = await resolvePhpSymbolImportPath( + projectRoot, + normalizedSpec, + preferredKind, + autoloadFiles, + ); + if (symbolResolved && !isPhpComposerClassmapExcluded(symbolResolved, composerConfig)) { + phpImportResolutionCache.set(cacheKey, symbolResolved); + return symbolResolved; + } + + phpImportResolutionCache.set(cacheKey, null); + return null; + } + } + + const symbolResolved = await resolvePhpSymbolImportPath(projectRoot, normalizedSpec, preferredKind); + if (symbolResolved) { + phpImportResolutionCache.set(cacheKey, symbolResolved); + return symbolResolved; + } + + const pathLikeResolved = await resolvePathLikePhpModule(projectRoot, normalizedSpec.replace(/\\/g, "/")); + phpImportResolutionCache.set(cacheKey, pathLikeResolved); + return pathLikeResolved; +} + +export function clearPhpResolutionCaches(): void { + phpImportResolutionCache.clear(); + phpSymbolIndexCache.clear(); + phpProjectSymbolIndexCache.clear(); + phpComposerConfigCache.clear(); + phpComposerAutoloadFileCache.clear(); +} From 24980a4bcd04d825759d16107e794fe1cbaffa39 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 17:55:13 -0400 Subject: [PATCH 10/25] Consolidate concurrency helpers --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/agent/explain.ts | 9 +- src/graph-builder.ts | 10 +- src/impact/collect.ts | 2 +- src/impact/direct.ts | 2 +- src/impact/report-suggestions.ts | 50 +++++----- src/indexer/build-index.ts | 40 +++----- src/review/summaries.ts | 20 +--- src/sql/review.ts | 21 +++-- src/sql/sourceGraph.ts | 2 +- src/util.ts | 72 ++++++++++++-- src/util/concurrency.ts | 129 ++++++++++++++++++++++++++ src/util/projectFiles.ts | 2 +- src/util/resolution.ts | 50 +--------- src/util/resolution/php.ts | 2 +- src/util/resolution/projectSymbols.ts | 2 +- src/util/semaphore.ts | 96 +------------------ tests/coverage-targeted.test.ts | 19 ++-- tests/map-limit.test.ts | 28 +++++- 19 files changed, 301 insertions(+), 257 deletions(-) create mode 100644 src/util/concurrency.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index f74ec40c..90540bbe 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -84,7 +84,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Keep `src/util/resolution.ts` as a facade only. - Add tests for cache clearing and language-specific resolution parity after the split. -- [ ] Move generic concurrency helpers out of resolution code and consolidate duplicates. +- [x] Move generic concurrency helpers out of resolution code and consolidate duplicates. - Finding: `src/util/resolution.ts` exports `mapLimit`, `src/review.ts` has a local `runWithConcurrency`, and `src/util/semaphore.ts` has `mapLimitSemaphore`. - Target: a single `src/util/concurrency.ts` with documented behavior for order preservation, invalid limits, and rejection handling. - Update users in `graph-builder`, `indexer/build-index`, impact modules, SQL modules, review, and agent explain. diff --git a/src/agent/explain.ts b/src/agent/explain.ts index 71ac78c3..e5c1bb8e 100644 --- a/src/agent/explain.ts +++ b/src/agent/explain.ts @@ -10,13 +10,8 @@ import { extractSqlFactsFromSource, sqlObjectBaseName } from "../sql/extractFact import type { SqlStatementFact } from "../sql/types.js"; import type { Range } from "../types.js"; import { normalizePath } from "../util.js"; -import { mapLimit } from "../util/resolution.js"; -import { - boundAgentList, - defaultAgentLimit, - emptyAgentBoundedList, - type BoundedAgentList, -} from "./bounds.js"; +import { mapLimit } from "../util/concurrency.js"; +import { boundAgentList, defaultAgentLimit, emptyAgentBoundedList, type BoundedAgentList } from "./bounds.js"; import { formatAgentFileHandle, formatAgentSqlHandle, diff --git a/src/graph-builder.ts b/src/graph-builder.ts index f4537e4b..e62b16ea 100644 --- a/src/graph-builder.ts +++ b/src/graph-builder.ts @@ -2,7 +2,8 @@ import path from "node:path"; import { isUnsupportedParserInputError } from "./languages/filePrep.js"; import type { Edge, Graph } from "./types.js"; -import { loadWorkspaceConfig, normalizeResolutionHints, mapLimit } from "./util.js"; +import { loadWorkspaceConfig, normalizeResolutionHints } from "./util.js"; +import { mapLimit } from "./util/concurrency.js"; import { logWithLevel, type LogLevel } from "./logging.js"; import { isNativeRequiredUnavailableError, type NativeRuntimeMode } from "./native/treeSitterNative.js"; import { initNativeBackendReport } from "./native/nativeBackendReport.js"; @@ -159,7 +160,12 @@ export async function collectGraph( const allEdges = filePromises; const newEdges = allEdges.flat(); - const angularJsEdges = await collectAngularJsFrameworkEdges(projectRoot, filesToCollect, workspaceConfig, opts?.parsed); + const angularJsEdges = await collectAngularJsFrameworkEdges( + projectRoot, + filesToCollect, + workspaceConfig, + opts?.parsed, + ); addEdgeTargetsToGraph(angularJsEdges); graph.edges = mergeUniqueEdges(graph.edges, newEdges, angularJsEdges); return graph; diff --git a/src/impact/collect.ts b/src/impact/collect.ts index 64914206..daad16aa 100644 --- a/src/impact/collect.ts +++ b/src/impact/collect.ts @@ -1,5 +1,5 @@ import type { ProjectIndex } from "../indexer.js"; -import { mapLimit } from "../util.js"; +import { mapLimit } from "../util/concurrency.js"; import { analyzeImpact } from "./analyzer.js"; import { locateChangedSymbolsWithLines } from "./map.js"; import { createImpactIgnoreMatcher, normalizeImpactDiffFiles } from "./path.js"; diff --git a/src/impact/direct.ts b/src/impact/direct.ts index b4847219..b413ca24 100644 --- a/src/impact/direct.ts +++ b/src/impact/direct.ts @@ -1,7 +1,7 @@ import type { FileId } from "../types.js"; import type { ProjectIndex, SymbolDef } from "../indexer.js"; import { findReferences } from "../indexer.js"; -import { Semaphore } from "../util/semaphore.js"; +import { Semaphore } from "../util/concurrency.js"; import type { ChangedSymbol, ImpactItem, ImpactOptions, ImpactReason } from "./types.js"; import { calculateSeverity, selectStrongerImpactReason } from "./severity.js"; diff --git a/src/impact/report-suggestions.ts b/src/impact/report-suggestions.ts index d547ddb1..c5b8e57c 100644 --- a/src/impact/report-suggestions.ts +++ b/src/impact/report-suggestions.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { SymbolKind, type ProjectIndex, findReferences } from "../indexer.js"; -import { mapLimit, resolveFilePathFromRoot } from "../util.js"; +import { resolveFilePathFromRoot } from "../util.js"; +import { mapLimit } from "../util/concurrency.js"; import { listCandidateTestFiles } from "./context.js"; import { collectHunkLineText, collectRemovedLines } from "./hunks.js"; import { normalizeImpactFilePath } from "./path.js"; @@ -116,12 +117,11 @@ function classifyConfigImpact( const relImporters = blastRadius.importers .slice(0, 5) .map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")); - const importerSummary = - blastRadius.importers.length - ? `Likely impacted importer files: ${relImporters.join(", ")}${ - blastRadius.importers.length > relImporters.length ? ", ..." : "" - }.` - : "No existing imports currently match these aliases."; + const importerSummary = blastRadius.importers.length + ? `Likely impacted importer files: ${relImporters.join(", ")}${ + blastRadius.importers.length > relImporters.length ? ", ..." : "" + }.` + : "No existing imports currently match these aliases."; return { details: `TypeScript/JavaScript path aliases changed (${blastRadius.aliases.join(", ")}). ${importerSummary}`, confidence: "high", @@ -165,10 +165,9 @@ function classifyConfigImpact( lineSignals.includes("dependson") || lineSignals.includes("cache") || lineSignals.includes("outputs"); - const scopeSummary = - workspaceManifests.length - ? `${workspaceManifests.length} workspace package manifest(s) discovered.` - : "Workspace package manifests were not discovered."; + const scopeSummary = workspaceManifests.length + ? `${workspaceManifests.length} workspace package manifest(s) discovered.` + : "Workspace package manifests were not discovered."; return { details: affectsTasks ? `Monorepo task orchestration config changed (${path.basename(change.path)}); task dependency graph, caching, or outputs may shift across packages. ${scopeSummary}` @@ -403,16 +402,20 @@ async function collectUntestedChangeSuggestions( }; const suggestionEntries = await mapLimit(changedSymbols, 8, async (symbol) => { - const refs = await findReferences(index, { - def: { - file: symbol.file, - localName: symbol.name, - kind: symbol.kind, - range: symbol.range, + const refs = await findReferences( + index, + { + def: { + file: symbol.file, + localName: symbol.name, + kind: symbol.kind, + range: symbol.range, + }, }, - }, { - maxReferences: UNTESTED_CHANGE_REFERENCE_SCAN_LIMIT, - }); + { + maxReferences: UNTESTED_CHANGE_REFERENCE_SCAN_LIMIT, + }, + ); if (refs.status !== "ok") return undefined; const hasTestRef = refs.references.some((entry) => testFiles.has(entry.file)); @@ -442,10 +445,9 @@ async function collectUntestedChangeSuggestions( }); const suggestedCommand = inferTestCommand(candidateNames); - const details = - candidateNames.length - ? `Changed symbol has no discovered references in test files. ${coverageSummary} Candidate tests: ${candidateNames.join(", ")}. Fan-in for this file is ${fanIn}. Suggested command: ${suggestedCommand}` - : `Changed symbol has no discovered references in test files. ${coverageSummary} Fan-in for this file is ${fanIn}. Suggested command: ${suggestedCommand}`; + const details = candidateNames.length + ? `Changed symbol has no discovered references in test files. ${coverageSummary} Candidate tests: ${candidateNames.join(", ")}. Fan-in for this file is ${fanIn}. Suggested command: ${suggestedCommand}` + : `Changed symbol has no discovered references in test files. ${coverageSummary} Fan-in for this file is ${fanIn}. Suggested command: ${suggestedCommand}`; return { file: symbol.file, diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index db6aa2f2..79c335a1 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -11,13 +11,13 @@ import { getGitBlobHashes, listChangedFiles, clearImportResolutionCaches, - mapLimit, stringifyUnknown, assertFilePathWithinRoot, normalizePath, resolveSpecifier, resolveWorkspacePackage, } from "../util.js"; +import { mapLimit } from "../util/concurrency.js"; import { logWithLevel, type LogLevel } from "../logging.js"; import { collectGraph, collectEdgesForFile } from "../graphs.js"; import { buildGraphAdjacency } from "../graphs/adjacency.js"; @@ -89,12 +89,7 @@ import { } from "./types.js"; import { isJsFallbackUnavailableError, isJsSyntaxTree } from "../jsFallback.js"; import { isUnsupportedParserInputError } from "../languages/filePrep.js"; -import { - buildSqlFactCache, - buildSqlModuleIndex, - sqlCorpusSignature, - type SqlFactCache, -} from "../sql/sourceGraph.js"; +import { buildSqlFactCache, buildSqlModuleIndex, sqlCorpusSignature, type SqlFactCache } from "../sql/sourceGraph.js"; type IndexedFileGraphContext = { source: string; @@ -504,12 +499,11 @@ function expandStarImports(modules: Map): void { const target = modules.get(imp.resolved); if (!target) continue; const targetSupport = supportForFile(imp.resolved); - const exportedSymbols = - target.exports.filter((entry) => entry.type === "local").length - ? target.exports - .filter((entry): entry is Extract => entry.type === "local") - .map((entry) => entry.target) - : target.locals.filter((local) => !local.localName.startsWith("_")); + const exportedSymbols = target.exports.filter((entry) => entry.type === "local").length + ? target.exports + .filter((entry): entry is Extract => entry.type === "local") + .map((entry) => entry.target) + : target.locals.filter((local) => !local.localName.startsWith("_")); const seen = new Set(); for (const symbol of exportedSymbols) { if (!symbol.localName || seen.has(symbol.localName)) continue; @@ -620,8 +614,7 @@ async function writeIndexManifestSnapshot(args: { manifestReport: ManifestReport | undefined; allowEmpty?: boolean; }): Promise { - const files = - args.files instanceof Map ? Object.fromEntries(args.files) : args.files; + const files = args.files instanceof Map ? Object.fromEntries(args.files) : args.files; if (!Object.keys(files).length && !args.allowEmpty) return; const writeManifestStart = performance.now(); const lastCommit = await getGitHead(args.projectRoot); @@ -1044,15 +1037,8 @@ export async function buildProjectIndexIncremental( const graphOptions = normalizeGraphOptions(opts?.graph); const strictIncremental = opts?.incrementalStrict ?? false; if (strictIncremental && graphOptions.fast) graphOptions.fast = false; - const { - normalizedProjectRoot, - report, - timings, - totalStart, - cacheMode, - cacheEnabled, - onFallbackImportExtraction, - } = createIndexBuildRunState(projectRoot, opts, graphOptions); + const { normalizedProjectRoot, report, timings, totalStart, cacheMode, cacheEnabled, onFallbackImportExtraction } = + createIndexBuildRunState(projectRoot, opts, graphOptions); try { const manifestStart = performance.now(); const manifest = await loadManifest(projectRoot, opts); @@ -1104,11 +1090,7 @@ export async function buildProjectIndexIncremental( manifestReport.reason = "staleGitCommit"; manifestReport.reused = false; } - logWithLevel( - opts?.logLevel, - "warn", - "Warning: Manifest commit is no longer available; rebuilding full index.", - ); + logWithLevel(opts?.logLevel, "warn", "Warning: Manifest commit is no longer available; rebuilding full index."); const rebuiltIndex = await buildProjectIndexFromExport(projectRoot, opts, { ignoreExistingManifest: true }); if (manifestReport) { manifestReport.reason = "staleGitCommit"; diff --git a/src/review/summaries.ts b/src/review/summaries.ts index 66007b02..0628f270 100644 --- a/src/review/summaries.ts +++ b/src/review/summaries.ts @@ -13,6 +13,7 @@ import { import { locateChangedSymbolsWithLines, mapChangedLinesToSymbols } from "../impact/map.js"; import type { Hunk } from "../impact/types.js"; import type { FileId, Range } from "../types.js"; +import { mapLimit } from "../util/concurrency.js"; import { normalizePath, toProjectRelativePath } from "../util.js"; import type { ReviewDiagnostics, ReviewTimingReport } from "../review.js"; import type { DeletedFileSnapshot } from "./deleted.js"; @@ -178,23 +179,6 @@ function sameRange(left: Range, right: Range): boolean { return left.start.line === right.start.line && left.start.column === right.start.column; } -async function runWithConcurrency(items: T[], limit: number, worker: (item: T) => Promise): Promise { - const results: R[] = []; - let nextIndex = 0; - const safeLimit = Math.max(1, limit); - const runners = Array.from({ length: Math.min(safeLimit, items.length) }, async () => { - while (true) { - const current = nextIndex; - nextIndex += 1; - if (current >= items.length) break; - const item = items[current]!; - results[current] = await worker(item); - } - }); - await Promise.all(runners); - return results; -} - export async function summarizeChangedFiles(input: { projectRoot: string; index: ProjectIndex; @@ -301,7 +285,7 @@ export async function summarizeChangedFiles(input: { const referencesStart = performance.now(); const referenceResults = includeSymbolDetails && maxCallsites > 0 - ? await runWithConcurrency(defsToResolve, referenceConcurrency, async (def) => { + ? await mapLimit(defsToResolve, referenceConcurrency, async (def) => { const refs = await findReferences( index, { def }, diff --git a/src/sql/review.ts b/src/sql/review.ts index 9cb44689..1c19e8de 100644 --- a/src/sql/review.ts +++ b/src/sql/review.ts @@ -2,7 +2,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { listProjectFiles } from "../util/projectFiles.js"; import { normalizePath } from "../util/paths.js"; -import { mapLimit } from "../util/resolution.js"; +import { mapLimit } from "../util/concurrency.js"; import { extractSqlFactsFromSource, sqlObjectBaseName } from "./extractFacts.js"; import type { SqlBridgeReason, SqlStatementFact } from "./types.js"; @@ -79,14 +79,10 @@ async function collectSqlFacts( if (isSqlFile(normalized)) allSqlFiles.add(normalized); } - const factGroups = await mapLimit( - Array.from(allSqlFiles), - SQL_FACT_READ_CONCURRENCY, - async (filePath) => { - const source = await readExistingFile(filePath); - return source === null ? [] : extractSqlFactsFromSource(filePath, source); - }, - ); + const factGroups = await mapLimit(Array.from(allSqlFiles), SQL_FACT_READ_CONCURRENCY, async (filePath) => { + const source = await readExistingFile(filePath); + return source === null ? [] : extractSqlFactsFromSource(filePath, source); + }); return factGroups.flat(); } @@ -155,7 +151,12 @@ export async function collectSqlReviewContext( const changedSqlLiteralSources = await collectChangedSqlLiteralSources(changedFiles); if (changedSqlFiles.size === 0 && !changedSqlLiteralSources.length) return undefined; - const facts = await collectSqlFacts(projectRoot, changedFiles, !!changedSqlLiteralSources.length, options.projectFiles); + const facts = await collectSqlFacts( + projectRoot, + changedFiles, + !!changedSqlLiteralSources.length, + options.projectFiles, + ); if (!facts.length) return undefined; const literalObjects = collectChangedSqlLiteralObjects(changedSqlLiteralSources, facts); diff --git a/src/sql/sourceGraph.ts b/src/sql/sourceGraph.ts index 7195d973..a16af2a7 100644 --- a/src/sql/sourceGraph.ts +++ b/src/sql/sourceGraph.ts @@ -6,7 +6,7 @@ import type { ModuleIndex, SymbolDef } from "../indexer/types.js"; import { SymbolKind } from "../indexer/types.js"; import type { Edge, Range } from "../types.js"; import { normalizePath } from "../util/paths.js"; -import { mapLimit } from "../util/resolution.js"; +import { mapLimit } from "../util/concurrency.js"; import { extractSqlFactsFromSource, sqlObjectBaseName } from "./extractFacts.js"; import type { SqlFactKind, SqlStatementFact } from "./types.js"; diff --git a/src/util.ts b/src/util.ts index ed2e9cdc..c946851e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,12 +1,70 @@ export { sliceText, stringifyUnknown, toRange, unquote } from "./util/ast.js"; -export { maskJsLikeCommentsAndStrings, parseJsonc, stripJsLikeComments, stripJsonTrailingCommas, stripPythonCommentsAndStrings } from "./util/comments.js"; -export { getGitBlobHash, getGitBlobHashes, getGitHead, getUnifiedDiff, gitDiffArgs, isGitIndexSentinel, isGitRepo, isGitWorktreeSentinel, listChangedFiles } from "./util/git.js"; -export { assertFilePathWithinRoot, isAbsoluteFilePath, isFilePathWithinRoot, normalizePath, normalizeResolutionHints, resolveFilePathFromRoot, toProjectRelativePath } from "./util/paths.js"; -export { DEFAULT_PROJECT_FILE_IGNORES, DEFAULT_PROJECT_MANIFESTS, DEFAULT_PROJECT_PATTERNS, discoverProjectFiles, listProjectFiles } from "./util/projectFiles.js"; -export type { ProjectFileDiscoveryOptions, ProjectFileInfo, ProjectFileKind, ProjectFileRole, ProjectFileType } from "./util/projectFiles.js"; +export { + maskJsLikeCommentsAndStrings, + parseJsonc, + stripJsLikeComments, + stripJsonTrailingCommas, + stripPythonCommentsAndStrings, +} from "./util/comments.js"; +export { + getGitBlobHash, + getGitBlobHashes, + getGitHead, + getUnifiedDiff, + gitDiffArgs, + isGitIndexSentinel, + isGitRepo, + isGitWorktreeSentinel, + listChangedFiles, +} from "./util/git.js"; +export { + assertFilePathWithinRoot, + isAbsoluteFilePath, + isFilePathWithinRoot, + normalizePath, + normalizeResolutionHints, + resolveFilePathFromRoot, + toProjectRelativePath, +} from "./util/paths.js"; +export { + DEFAULT_PROJECT_FILE_IGNORES, + DEFAULT_PROJECT_MANIFESTS, + DEFAULT_PROJECT_PATTERNS, + discoverProjectFiles, + listProjectFiles, +} from "./util/projectFiles.js"; +export type { + ProjectFileDiscoveryOptions, + ProjectFileInfo, + ProjectFileKind, + ProjectFileRole, + ProjectFileType, +} from "./util/projectFiles.js"; export { extractJsTsDynamicSpecifiers, extractJsTsSpecifiers, extractPythonSpecifiers } from "./util/specifiers.js"; export type { ModuleSpecifier } from "./util/specifiers.js"; -export { fileExists, listWorkspacePackageResolutionCandidates, loadJSON, loadWorkspaceConfig, resolvePackageSubpath, resolveWorkspacePackage } from "./util/workspace.js"; +export { + fileExists, + listWorkspacePackageResolutionCandidates, + loadJSON, + loadWorkspaceConfig, + resolvePackageSubpath, + resolveWorkspacePackage, +} from "./util/workspace.js"; export type { WorkspaceConfig, WorkspacePackageInfo } from "./util/workspace.js"; -export { GRAPH_ONLY_RESOLUTION_EXTENSIONS, clearImportResolutionCaches, clearResolutionCaches, getGraphOnlyResolutionExtensions, getPhpComposerImplicitFiles, listResolutionCandidates, loadNearestTsconfigFor, mapLimit, resolveGoImportPath, resolveImportSpecifier, resolvePathLikeModule, resolvePythonModule, resolveSpecifier, resolveJvmPackageImportPaths } from "./util/resolution.js"; +export { mapLimit } from "./util/concurrency.js"; +export { + GRAPH_ONLY_RESOLUTION_EXTENSIONS, + clearImportResolutionCaches, + clearResolutionCaches, + getGraphOnlyResolutionExtensions, + getPhpComposerImplicitFiles, + listResolutionCandidates, + loadNearestTsconfigFor, + resolveGoImportPath, + resolveImportSpecifier, + resolvePathLikeModule, + resolvePythonModule, + resolveSpecifier, + resolveJvmPackageImportPaths, +} from "./util/resolution.js"; export type { FileId, MatchPathFn } from "./util/resolution.js"; diff --git a/src/util/concurrency.ts b/src/util/concurrency.ts new file mode 100644 index 00000000..333769da --- /dev/null +++ b/src/util/concurrency.ts @@ -0,0 +1,129 @@ +/** + * A simple semaphore implementation for bounding concurrent operations. + */ +export class Semaphore { + private permits: number; + private waitQueue: Array<() => void> = []; + + constructor(permits: number) { + if (!Number.isFinite(permits) || permits < 1) { + throw new Error(`Semaphore permits must be a positive number, got: ${permits}`); + } + this.permits = Math.floor(permits); + } + + async acquire(): Promise { + if (this.permits > 0) { + this.permits--; + return; + } + return new Promise((resolve) => { + this.waitQueue.push(resolve); + }); + } + + release(): void { + const next = this.waitQueue.shift(); + if (next) { + next(); + } else { + this.permits++; + } + } + + async withPermit(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + available(): number { + return this.permits; + } + + waiting(): number { + return this.waitQueue.length; + } +} + +/** + * Map over items with bounded concurrency, preserving input order. + * Invalid limits are treated as 1. The returned promise rejects on the first + * worker rejection and stops starting new work. + */ +export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + if (!items.length) return []; + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 1; + + const results = new Array(items.length); + let nextIndex = 0; + let activeCount = 0; + let resolveAll: (() => void) | null = null; + let rejectAll: ((err: unknown) => void) | null = null; + let aborted = false; + + const startNext = (): void => { + if (aborted) return; + while (activeCount < safeLimit && nextIndex < items.length) { + if (aborted) return; + const index = nextIndex++; + const item = items[index]!; + activeCount++; + + fn(item) + .then((result) => { + if (aborted) return; + results[index] = result; + activeCount--; + if (nextIndex < items.length) { + startNext(); + } else if (activeCount === 0 && resolveAll) { + resolveAll(); + } + }) + .catch((err) => { + if (aborted) return; + aborted = true; + activeCount--; + if (rejectAll) rejectAll(err); + }); + } + }; + + return new Promise((resolve, reject) => { + resolveAll = () => resolve(results); + rejectAll = reject; + startNext(); + if (!aborted && nextIndex >= items.length && activeCount === 0) { + resolve(results); + } + }); +} + +/** + * Map over items with bounded concurrency using a semaphore. + */ +export async function mapLimitSemaphore( + items: T[], + limit: number, + fn: (item: T, semaphore: Semaphore) => Promise, +): Promise { + const semaphore = new Semaphore(limit); + return await Promise.all(items.map((item) => semaphore.withPermit(() => fn(item, semaphore)))); +} + +let globalIOSemaphore: Semaphore | null = null; + +export function getIOSemaphore(limit = 100): Semaphore { + if (!globalIOSemaphore) { + globalIOSemaphore = new Semaphore(limit); + } + return globalIOSemaphore; +} + +export function resetIOSemaphore(): void { + globalIOSemaphore = null; +} diff --git a/src/util/projectFiles.ts b/src/util/projectFiles.ts index 628722fd..93af25c3 100644 --- a/src/util/projectFiles.ts +++ b/src/util/projectFiles.ts @@ -12,7 +12,7 @@ import { type ProjectFileInfo, } from "./projectFiles/definitions.js"; import { trimToNull } from "./projectFiles/parsers.js"; -import { mapLimitSemaphore } from "./semaphore.js"; +import { mapLimitSemaphore } from "./concurrency.js"; export type { ProjectFileDefinition, diff --git a/src/util/resolution.ts b/src/util/resolution.ts index c6a780b0..d594d9da 100644 --- a/src/util/resolution.ts +++ b/src/util/resolution.ts @@ -22,6 +22,7 @@ export { resolveJvmPackageImportPaths } from "./resolution/jvm.js"; export { getPhpComposerImplicitFiles } from "./resolution/php.js"; export { resolvePythonModule } from "./resolution/python.js"; export { loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; +export { mapLimit } from "./concurrency.js"; export { listResolutionCandidates } from "./resolutionCandidates.js"; const resolveSpecifierCache = new Map(); @@ -321,52 +322,3 @@ export function clearResolutionCaches(): void { clearTsconfigCache(); clearWorkspaceCaches(); } - -export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - if (!items.length) return []; - const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 1; - - const results = new Array(items.length); - let nextIndex = 0; - let activeCount = 0; - let resolveAll: (() => void) | null = null; - let rejectAll: ((err: unknown) => void) | null = null; - let aborted = false; - - const startNext = (): void => { - if (aborted) return; - while (activeCount < safeLimit && nextIndex < items.length) { - if (aborted) return; - const index = nextIndex++; - const item = items[index]!; - activeCount++; - - fn(item) - .then((result) => { - if (aborted) return; - results[index] = result; - activeCount--; - if (nextIndex < items.length) { - startNext(); - } else if (activeCount === 0 && resolveAll) { - resolveAll(); - } - }) - .catch((err) => { - if (aborted) return; - aborted = true; - activeCount--; - if (rejectAll) rejectAll(err); - }); - } - }; - - return new Promise((resolve, reject) => { - resolveAll = () => resolve(results); - rejectAll = reject; - startNext(); - if (!aborted && nextIndex >= items.length && activeCount === 0) { - resolve(results); - } - }); -} diff --git a/src/util/resolution/php.ts b/src/util/resolution/php.ts index e48d5f7c..67a3d85f 100644 --- a/src/util/resolution/php.ts +++ b/src/util/resolution/php.ts @@ -4,7 +4,7 @@ import { stringifyUnknown } from "../ast.js"; import { normalizePath } from "../paths.js"; import { listProjectFiles } from "../projectFiles.js"; import { listResolutionCandidates } from "../resolutionCandidates.js"; -import { mapLimitSemaphore } from "../semaphore.js"; +import { mapLimitSemaphore } from "../concurrency.js"; import { fileExists } from "../workspace.js"; import { findNearestFile } from "./files.js"; import { diff --git a/src/util/resolution/projectSymbols.ts b/src/util/resolution/projectSymbols.ts index 6b007b45..f559c42a 100644 --- a/src/util/resolution/projectSymbols.ts +++ b/src/util/resolution/projectSymbols.ts @@ -1,6 +1,6 @@ import { normalizePath } from "../paths.js"; import { listProjectFiles } from "../projectFiles.js"; -import { mapLimitSemaphore } from "../semaphore.js"; +import { mapLimitSemaphore } from "../concurrency.js"; export type LanguageProjectSymbolIndex = { files: string[]; diff --git a/src/util/semaphore.ts b/src/util/semaphore.ts index 0dcf58ee..7af8ff2c 100644 --- a/src/util/semaphore.ts +++ b/src/util/semaphore.ts @@ -1,95 +1 @@ -/** - * A simple semaphore implementation for bounding concurrent operations. - * Unlike mapLimit which only limits task starts, this tracks actual outstanding operations. - */ - -export class Semaphore { - private permits: number; - private waitQueue: Array<() => void> = []; - - constructor(permits: number) { - if (!Number.isFinite(permits) || permits < 1) { - throw new Error(`Semaphore permits must be a positive number, got: ${permits}`); - } - this.permits = Math.floor(permits); - } - - async acquire(): Promise { - if (this.permits > 0) { - this.permits--; - return; - } - return new Promise((resolve) => { - this.waitQueue.push(resolve); - }); - } - - release(): void { - const next = this.waitQueue.shift(); - if (next) { - next(); - } else { - this.permits++; - } - } - - /** - * Execute a function while holding a permit - */ - async withPermit(fn: () => Promise): Promise { - await this.acquire(); - try { - return await fn(); - } finally { - this.release(); - } - } - - /** - * Get current number of available permits - */ - available(): number { - return this.permits; - } - - /** - * Get number of waiters in queue - */ - waiting(): number { - return this.waitQueue.length; - } -} - -/** - * Map over items with bounded concurrency using a semaphore. - * Unlike basic mapLimit, this correctly handles nested async operations. - */ -export async function mapLimitSemaphore( - items: T[], - limit: number, - fn: (item: T, semaphore: Semaphore) => Promise, -): Promise { - const semaphore = new Semaphore(limit); - const results = await Promise.all(items.map((item) => semaphore.withPermit(() => fn(item, semaphore)))); - return results; -} - -/** - * Global I/O semaphore for file operations. - * Prevents EMFILE errors on systems with limited file descriptors. - */ -let globalIOSemaphore: Semaphore | null = null; - -export function getIOSemaphore(limit = 100): Semaphore { - if (!globalIOSemaphore) { - globalIOSemaphore = new Semaphore(limit); - } - return globalIOSemaphore; -} - -/** - * Reset the global I/O semaphore (useful for testing) - */ -export function resetIOSemaphore(): void { - globalIOSemaphore = null; -} +export { getIOSemaphore, mapLimit, mapLimitSemaphore, resetIOSemaphore, Semaphore } from "./concurrency.js"; diff --git a/tests/coverage-targeted.test.ts b/tests/coverage-targeted.test.ts index 4d3577b2..65c49a6c 100644 --- a/tests/coverage-targeted.test.ts +++ b/tests/coverage-targeted.test.ts @@ -6,12 +6,7 @@ import { symbolIdentifier, type SymbolHash, } from "../src/util/symbolHash.js"; -import { - getIOSemaphore, - mapLimitSemaphore, - resetIOSemaphore, - Semaphore, -} from "../src/util/semaphore.js"; +import { getIOSemaphore, mapLimitSemaphore, resetIOSemaphore, Semaphore } from "../src/util/concurrency.js"; import { sliceText, stringifyUnknown, toRange, unquote } from "../src/util/ast.js"; import { graphToTriples, type SymbolGraph, type SymbolNode } from "../src/index.js"; import { SymbolKind, type ExportEntry, type SymbolDef } from "../src/indexer.js"; @@ -183,11 +178,19 @@ describe("targeted coverage for graph triples and native worker fallback", () => importBindings: [ { patternIndex: 0, - captures: [capture("stmt", 'import alpha from "pkg-alpha";'), capture("def", "alpha"), capture("from", '"pkg-alpha"')], + captures: [ + capture("stmt", 'import alpha from "pkg-alpha";'), + capture("def", "alpha"), + capture("from", '"pkg-alpha"'), + ], }, { patternIndex: 1, - captures: [capture("stmt", 'import * as beta from "pkg-beta";'), capture("ns", "beta"), capture("from", '"pkg-beta"')], + captures: [ + capture("stmt", 'import * as beta from "pkg-beta";'), + capture("ns", "beta"), + capture("from", '"pkg-beta"'), + ], }, { patternIndex: 2, diff --git a/tests/map-limit.test.ts b/tests/map-limit.test.ts index df48fd2d..0914dca8 100644 --- a/tests/map-limit.test.ts +++ b/tests/map-limit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mapLimit } from "../src/util.js"; +import { mapLimit } from "../src/util/concurrency.js"; describe("mapLimit", () => { it("treats non-positive concurrency as single-threaded work instead of hanging", async () => { @@ -13,4 +13,30 @@ describe("mapLimit", () => { 5, 10, 15, ]); }); + + it("preserves input order while running work concurrently", async () => { + const started: number[] = []; + const result = await mapLimit([30, 10, 20], 2, async (value) => { + started.push(value); + await new Promise((resolve) => setTimeout(resolve, value)); + return value / 10; + }); + + expect(started.slice(0, 2)).toEqual([30, 10]); + expect(result).toEqual([3, 1, 2]); + }); + + it("rejects on worker failure and stops starting queued work", async () => { + const started: number[] = []; + await expect( + mapLimit([1, 2, 3, 4], 2, async (value) => { + started.push(value); + if (value === 2) throw new Error("boom"); + await new Promise((resolve) => setTimeout(resolve, 10)); + return value; + }), + ).rejects.toThrow("boom"); + + expect(started).toEqual([1, 2]); + }); }); From a3ba56b0d7997ddc1307ba723d069860677c8faf Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 18:10:53 -0400 Subject: [PATCH 11/25] Reduce internal barrel imports --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/agent-tools.ts | 18 +++--- src/agent/artifact.ts | 26 +++++---- src/agent/explain.ts | 9 +-- src/agent/normalize.ts | 2 +- src/agent/search.ts | 56 ++++++++++++------- src/agent/session.ts | 8 +-- src/cli.ts | 12 ++-- src/cli/context.ts | 3 +- src/cli/graph.ts | 18 +++--- src/cli/graphDelta.ts | 7 ++- src/cli/graphQueries.ts | 15 ++--- src/cli/grep.ts | 4 +- src/cli/impact.ts | 13 ++--- src/cli/index.ts | 7 ++- src/cli/inspect.ts | 17 +++--- src/cli/navigation.ts | 6 +- src/cli/review.ts | 4 +- src/cli/sql.ts | 2 +- src/config.ts | 2 +- src/documentLinks.ts | 2 +- src/documentLinks/asciidoc.ts | 2 +- src/documentLinks/html.ts | 2 +- src/documentLinks/markdown.ts | 2 +- src/documentLinks/rst.ts | 8 +-- src/documentLinks/sfc.ts | 2 +- src/documentLinks/shared.ts | 2 +- src/graph-builder.ts | 3 +- src/graph-edge-collector.ts | 8 +-- src/graphs/angularjs.ts | 3 +- src/graphs/edgeResolution.ts | 8 +-- src/graphs/grep.ts | 2 +- src/graphs/specifiers.ts | 3 +- src/graphs/symbol-graph-detailed/ast.ts | 2 +- .../symbol-graph-detailed/edgePasses.ts | 2 +- .../symbol-graph-detailed/memberChains.ts | 2 +- src/impact/analyzer.ts | 2 +- src/impact/collect.ts | 2 +- src/impact/context.ts | 6 +- src/impact/direct.ts | 4 +- src/impact/index.ts | 2 +- src/impact/map.ts | 4 +- src/impact/path.ts | 6 +- src/impact/providers/base.ts | 2 +- src/impact/report-suggestions.ts | 5 +- src/impact/report.ts | 8 ++- src/impact/reportCompact.ts | 7 +-- src/impact/reportFull.ts | 7 +-- src/impact/severity.ts | 2 +- src/impact/streaming.ts | 4 +- src/impact/suggestions.ts | 7 ++- src/impact/testPatterns.ts | 4 +- src/impact/transitive.ts | 2 +- src/impact/types.ts | 4 +- src/indexer/build-cache/manifest.ts | 9 ++- src/indexer/build-cache/options.ts | 15 ++--- src/indexer/build-cache/reports.ts | 2 +- src/indexer/build-index.ts | 26 +++------ src/indexer/imports.ts | 7 +-- src/indexer/imports/graphOnly.ts | 6 +- src/indexer/imports/jsFallback.ts | 2 +- src/indexer/imports/languageSpecific.ts | 5 +- src/indexer/imports/nativeCaptures.ts | 12 +--- src/indexer/imports/python.ts | 9 +-- src/indexer/locals-and-exports.ts | 3 +- src/indexer/navigation-goto.ts | 2 +- src/indexer/navigation-php.ts | 2 +- src/indexer/navigation-references.ts | 2 +- src/indexer/navigation.ts | 3 +- src/indexer/parse-context.ts | 2 +- src/indexer/reference-context.ts | 2 +- src/indexer/scope.ts | 2 +- src/indexer/shared.ts | 2 +- src/indexer/types.ts | 2 +- src/languages/importStatementParsers.ts | 2 +- src/mcp/security.ts | 2 +- src/mcp/server.ts | 6 +- src/native/execution.ts | 2 +- src/native/nativeBackendReport.ts | 2 +- src/native/runtime.ts | 2 +- src/presets.ts | 2 +- src/query/parser.ts | 2 +- src/query/symbols.ts | 2 +- src/review.ts | 11 ++-- src/review/candidates.ts | 4 +- src/review/changes.ts | 3 +- src/review/deleted.ts | 19 ++----- src/review/report.ts | 5 +- src/review/summaries.ts | 13 ++--- src/session.ts | 13 ++++- src/sqlite/types.ts | 2 +- src/sqlite/write.ts | 2 +- src/triples.ts | 2 +- src/util/lazySymbols.ts | 9 +-- src/util/memberAccess.ts | 2 +- src/util/symbolHash.ts | 2 +- tests/impact-context-large.test.ts | 4 +- tests/package-metadata.test.ts | 32 ++++++++--- tests/review.test.ts | 38 +++++++------ tests/session.test.ts | 40 ++++++------- 100 files changed, 350 insertions(+), 350 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 90540bbe..20e2c572 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -90,7 +90,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Update users in `graph-builder`, `indexer/build-index`, impact modules, SQL modules, review, and agent explain. - Keep `tests/map-limit.test.ts`, and add rejection/concurrency-order coverage if missing. -- [ ] Reduce internal dependence on broad barrels. +- [x] Reduce internal dependence on broad barrels. - Finding: many implementation modules import from `../util.js`, `../graphs.js`, and `../indexer.js`, increasing fan-in on broad facade files and making public/internal boundaries blurry. - Target: implementation modules import direct leaf modules unless they are intentionally using a stable domain facade. - Add or extend package metadata/source-structure tests to prevent imports through the root public API and to limit broad internal barrel usage where practical. diff --git a/src/agent-tools.ts b/src/agent-tools.ts index 9a2127a1..7dabc3c5 100644 --- a/src/agent-tools.ts +++ b/src/agent-tools.ts @@ -1,4 +1,6 @@ -import { buildProjectIndex, listSymbols, symbolId, goToDefinition, findReferences } from "./indexer.js"; +import { buildProjectIndex } from "./indexer/build-index.js"; +import { listSymbols, symbolId } from "./indexer/symbols.js"; +import { goToDefinition, findReferences } from "./indexer/navigation.js"; import type { ImportBinding, ProjectIndex, @@ -10,15 +12,13 @@ import type { import { analyzeImpactFromDiff } from "./impact/index.js"; import type { CompactImpactReport, ImpactOptions, ImpactReport } from "./impact/types.js"; import type { Edge, Range } from "./types.js"; -import { collectGraph, getDependencies, getReverseDependencies, getHotspots } from "./graphs.js"; +import { collectGraph } from "./graph-builder.js"; +import { getDependencies, getReverseDependencies } from "./graphs/queries.js"; +import { getHotspots } from "./graphs/hotspots.js"; import type { NativeRuntimeMode } from "./native/treeSitterNative.js"; -import { - fileExists, - isFilePathWithinRoot, - listProjectFiles, - normalizePath, - resolveFilePathFromRoot, -} from "./util.js"; +import { fileExists } from "./util/workspace.js"; +import { isFilePathWithinRoot, normalizePath, resolveFilePathFromRoot } from "./util/paths.js"; +import { listProjectFiles } from "./util/projectFiles.js"; import { boundAgentList, defaultAgentLimit, normalizeAgentLimit } from "./agent/bounds.js"; import { normalizeAgentOutputPath } from "./agent/normalize.js"; diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 9c410e29..2288bda1 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -1,9 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { getHotspots, type SymbolNode } from "../graphs.js"; +import { getHotspots } from "../graphs/hotspots.js"; +import { type SymbolNode } from "../graphs/symbol-graph.js"; import { defNodeId } from "../graphs/symbol-graph.js"; import { queryGraphSqliteRaw, writeGraphSqlite } from "../sqlite.js"; -import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "../util.js"; +import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "../util/paths.js"; import { formatAgentSqlHandle, formatAgentSymbolHandle } from "./handles.js"; import { normalizeAgentFilePath } from "./normalize.js"; import { createAgentSession } from "./session.js"; @@ -188,7 +189,9 @@ function normalizeArtifactSelection(request: CodegraphArtifactBuildRequest): { async function validateOutputDirectory(outDir: string, force: boolean): Promise { const entries = await readDirectoryIfPresent(outDir); if (entries.length && !force) { - throw new Error(`Refusing to write into non-empty output directory: ${outDir}. Pass --force to overwrite artifacts.`); + throw new Error( + `Refusing to write into non-empty output directory: ${outDir}. Pass --force to overwrite artifacts.`, + ); } } @@ -486,9 +489,12 @@ function buildPortableSymbolIdMap(snapshot: AgentProjectSnapshot): Map, relFile: string, node: SymbolNode): string { - const base = ["graph-symbol", encodeURIComponent(relFile), encodeURIComponent(node.kind), encodeURIComponent(node.name)].join( - ":", - ); + const base = [ + "graph-symbol", + encodeURIComponent(relFile), + encodeURIComponent(node.kind), + encodeURIComponent(node.name), + ].join(":"); let candidate = base; let suffix = 2; while (seen.has(candidate)) { @@ -519,9 +525,7 @@ async function filterSnapshotForOutputDirectory( return edge.to.type !== "file" || !isOutputFile(edge.to.path); }), }; - const symbols = new Map( - [...snapshot.symbolGraph.nodes.entries()].filter(([, node]) => !isOutputFile(node.file)), - ); + const symbols = new Map([...snapshot.symbolGraph.nodes.entries()].filter(([, node]) => !isOutputFile(node.file))); const symbolGraph = { nodes: symbols, edges: snapshot.symbolGraph.edges.filter((edge) => symbols.has(edge.from) && symbols.has(edge.to)), @@ -680,7 +684,9 @@ function collectExportedSymbols( }); } -function collectSqlObjects(snapshot: AgentProjectSnapshot): Array<{ name: string; kind: string; file: string; handle: string }> { +function collectSqlObjects( + snapshot: AgentProjectSnapshot, +): Array<{ name: string; kind: string; file: string; handle: string }> { return [...snapshot.symbolGraph.nodes.values()] .filter((node) => node.kind === "table" || node.kind === "view" || node.kind === "index" || node.kind === "routine") .map((node) => { diff --git a/src/agent/explain.ts b/src/agent/explain.ts index e5c1bb8e..8a81c13f 100644 --- a/src/agent/explain.ts +++ b/src/agent/explain.ts @@ -1,15 +1,16 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { findReferences } from "../indexer.js"; +import { findReferences } from "../indexer/navigation.js"; import type { Reference, SymbolDef } from "../indexer/types.js"; -import { getDependencies, getHotspots, getReverseDependencies } from "../graphs.js"; +import { getDependencies, getReverseDependencies } from "../graphs/queries.js"; +import { getHotspots } from "../graphs/hotspots.js"; import { defNodeId } from "../graphs/symbol-graph.js"; -import type { SymbolNode } from "../graphs.js"; +import { type SymbolNode } from "../graphs/symbol-graph.js"; import { buildReviewReport } from "../review.js"; import { extractSqlFactsFromSource, sqlObjectBaseName } from "../sql/extractFacts.js"; import type { SqlStatementFact } from "../sql/types.js"; import type { Range } from "../types.js"; -import { normalizePath } from "../util.js"; +import { normalizePath } from "../util/paths.js"; import { mapLimit } from "../util/concurrency.js"; import { boundAgentList, defaultAgentLimit, emptyAgentBoundedList, type BoundedAgentList } from "./bounds.js"; import { diff --git a/src/agent/normalize.ts b/src/agent/normalize.ts index 8056f1a7..dbfd9999 100644 --- a/src/agent/normalize.ts +++ b/src/agent/normalize.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { normalizePath, toProjectRelativePath } from "../util.js"; +import { normalizePath, toProjectRelativePath } from "../util/paths.js"; import { quoteShellArg } from "./shell.js"; export function normalizeAgentFilePath(root: string, file: string): string { diff --git a/src/agent/search.ts b/src/agent/search.ts index 6b4ef4fe..57b6c3dd 100644 --- a/src/agent/search.ts +++ b/src/agent/search.ts @@ -6,8 +6,8 @@ import { chunkFile } from "../chunking/chunkFile.js"; import type { SymbolDef } from "../indexer/types.js"; import type { Range } from "../types.js"; import { defNodeId } from "../graphs/symbol-graph.js"; -import type { SymbolNode } from "../graphs.js"; -import { normalizePath } from "../util.js"; +import { type SymbolNode } from "../graphs/symbol-graph.js"; +import { normalizePath } from "../util/paths.js"; import { boundAgentList, defaultAgentLimit } from "./bounds.js"; import { formatAgentChunkHandle, @@ -90,7 +90,10 @@ export type AgentSearchResponse = { results: AgentSearchResult[]; }; -type MutableSearchResult = Omit & { +type MutableSearchResult = Omit< + AgentSearchResult, + "rankReasons" | "evidence" | "neighbors" | "followUps" | "omittedCounts" +> & { rankReasons: Set; evidence: AgentSearchEvidence[]; neighbors: Map; @@ -159,14 +162,19 @@ export function formatAgentSearchResponse(response: AgentSearchResponse): string } return response.results .map((result, index) => { - const location = result.range ? `${result.file}:${result.range.start.line}:${result.range.start.column}` : result.file; + const location = result.range + ? `${result.file}:${result.range.start.line}:${result.range.start.column}` + : result.file; const reasons = result.rankReasons.slice(0, 3).join("; "); return `${index + 1}. ${result.label} [${result.kind}] ${location} score=${result.score}\n ${reasons}`; }) .join("\n"); } -async function searchSnapshot(snapshot: AgentProjectSnapshot, request: AgentSearchRequest): Promise { +async function searchSnapshot( + snapshot: AgentProjectSnapshot, + request: AgentSearchRequest, +): Promise { const mode = request.mode ?? "hybrid"; const tokens = tokenizeQuery(request.query); const resultMap = new Map(); @@ -191,12 +199,17 @@ async function searchSnapshot(snapshot: AgentProjectSnapshot, request: AgentSear } if (request.from !== undefined && (mode === "hybrid" || mode === "graph")) { - applyGraphNeighborhood(snapshot, resultMap, getFileNeighborIndex(), tokens, request.from, normalizeDepth(request.depth)); + applyGraphNeighborhood( + snapshot, + resultMap, + getFileNeighborIndex(), + tokens, + request.from, + normalizeDepth(request.depth), + ); } - const candidates = [...resultMap.values()] - .filter((result) => result.score > 0) - .sort(compareResults); + const candidates = [...resultMap.values()].filter((result) => result.score > 0).sort(compareResults); const boundedResults = boundAgentList(candidates, limit); const results = boundedResults.items.map(finalizeResult); @@ -240,9 +253,7 @@ function normalizeSearchText(input: string): string { } function splitCamelCase(input: string): string { - return input - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2"); + return input.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2"); } function matchTokenScore(text: string, tokens: string[]): { score: number; matched: string[] } { @@ -516,7 +527,9 @@ function resolveAnchorFiles(snapshot: AgentProjectSnapshot, from: string): Set [normalizePath(file), normalizePath(file)])); - const absoluteCandidate = path.isAbsolute(candidate) ? normalizePath(candidate) : normalizePath(path.resolve(snapshot.root, candidate)); + const absoluteCandidate = path.isAbsolute(candidate) + ? normalizePath(candidate) + : normalizePath(path.resolve(snapshot.root, candidate)); return normalizedFiles.get(absoluteCandidate) ?? null; } @@ -561,9 +574,12 @@ async function readSearchableFile(file: string): Promise { } } -function buildTextChunks(file: string, text: string): Array<{ name?: string; text: string; startLine: number; endLine: number }> { +function buildTextChunks( + file: string, + text: string, +): Array<{ name?: string; text: string; startLine: number; endLine: number }> { const support = supportForFile(file); - const languageId = support ? CHUNK_LANGUAGE_ALIASES[support.id] ?? support.id : undefined; + const languageId = support ? (CHUNK_LANGUAGE_ALIASES[support.id] ?? support.id) : undefined; const language = languageId ? LANG_CONFIGS[languageId] : undefined; if (language) { try { @@ -598,13 +614,13 @@ function makeSnippet(text: string, tokens: string[]): string { const lines = text.split(/\r?\n/); const matchIndex = lines.findIndex((line) => matchTokenScore(line, tokens).score > 0); const index = matchIndex >= 0 ? matchIndex : 0; - return lines.slice(Math.max(0, index - 1), Math.min(lines.length, index + 2)).join("\n").trim(); + return lines + .slice(Math.max(0, index - 1), Math.min(lines.length, index + 2)) + .join("\n") + .trim(); } -function upsertResult( - resultMap: Map, - base: SearchResultBase, -): MutableSearchResult { +function upsertResult(resultMap: Map, base: SearchResultBase): MutableSearchResult { const existing = resultMap.get(base.handle); if (existing) return existing; const result: MutableSearchResult = { diff --git a/src/agent/session.ts b/src/agent/session.ts index 12a83fbf..e04f05c5 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,9 +1,9 @@ -import { buildProjectIndexFromFiles } from "../indexer.js"; +import { buildProjectIndexFromFiles } from "../indexer/build-index.js"; import type { ProjectIndex } from "../indexer/types.js"; -import { buildSymbolGraphDetailed } from "../graphs.js"; -import type { SymbolGraph } from "../graphs.js"; +import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; +import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; -import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util.js"; +import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; export type AgentProjectSnapshot = { diff --git a/src/cli.ts b/src/cli.ts index df954a3a..91267be6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import path from "node:path"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import type { BuildOptions } from "./indexer/types.js"; -import { type GraphBuildOptions } from "./graphs.js"; +import { type GraphBuildOptions } from "./graphs/types.js"; import { type NativeRuntimeMode } from "./native/treeSitterNative.js"; import { handleChunkCommand } from "./cli/chunk.js"; import { @@ -42,13 +42,9 @@ import { handleSearchCommand } from "./cli/search.js"; import { handleSkillCommand } from "./cli/skill.js"; import { handleSqlCommand } from "./cli/sql.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "./config.js"; -import { - listChangedFiles, - listProjectFiles, - normalizePath, - resolveFilePathFromRoot, - type ProjectFileDiscoveryOptions, -} from "./util.js"; +import { listChangedFiles } from "./util/git.js"; +import { listProjectFiles, type ProjectFileDiscoveryOptions } from "./util/projectFiles.js"; +import { normalizePath, resolveFilePathFromRoot } from "./util/paths.js"; export { isCliDiscoveryRelativePathInside } from "./cli/context.js"; diff --git a/src/cli/context.ts b/src/cli/context.ts index 9ce22dc9..e5b4593c 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -5,7 +5,8 @@ import path from "node:path"; import picomatch from "picomatch"; import type { BuildReport } from "../indexer/types.js"; import type { ReviewBuildReport } from "../review.js"; -import { normalizePath, resolveFilePathFromRoot, type ProjectFileDiscoveryOptions } from "../util.js"; +import { normalizePath, resolveFilePathFromRoot } from "../util/paths.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { isRelativePathInside } from "../util/projectFiles.js"; import { isCliValueOption } from "./options.js"; diff --git a/src/cli/graph.ts b/src/cli/graph.ts index 5c752a2a..b0c66e48 100644 --- a/src/cli/graph.ts +++ b/src/cli/graph.ts @@ -2,24 +2,24 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { performance } from "node:perf_hooks"; +import { buildSymbolGraph, type SymbolGraph } from "../graphs/symbol-graph.js"; +import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; +import { collectGraph } from "../graph-builder.js"; +import { graphToDOT, graphToMermaid } from "../graphs/render.js"; import { - buildSymbolGraph, - buildSymbolGraphDetailed, - collectGraph, - graphToDOT, graphToDOTSymbols, graphToDOTSymbolsWithFiles, - graphToMermaid, graphToMermaidSymbols, graphToMermaidSymbolsWithFiles, - type SymbolGraph, -} from "../graphs.js"; -import { buildProjectIndexFromFiles, buildProjectIndexIncremental, type BuildReport } from "../indexer.js"; +} from "../graphs/symbol-render.js"; +import { buildProjectIndexFromFiles, buildProjectIndexIncremental } from "../indexer/build-index.js"; +import { type BuildReport } from "../indexer/types.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; import { updateGraphSqlite, writeGraphSqlite } from "../sqlite.js"; import { buildSqlArtifactGraphFromFiles } from "../sql/index.js"; import type { Graph } from "../types.js"; -import { normalizePath, resolveFilePathFromRoot, type ProjectFileDiscoveryOptions } from "../util.js"; +import { normalizePath, resolveFilePathFromRoot } from "../util/paths.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parseNonNegativeIntegerOption, diff --git a/src/cli/graphDelta.ts b/src/cli/graphDelta.ts index a387c5fa..399ad650 100644 --- a/src/cli/graphDelta.ts +++ b/src/cli/graphDelta.ts @@ -1,8 +1,9 @@ import fsp from "node:fs/promises"; -import { buildGraphDelta, type IncrementalBuildOptions } from "../indexer.js"; -import type { GraphBuildOptions } from "../graphs.js"; +import { buildGraphDelta } from "../indexer/build-index.js"; +import { type IncrementalBuildOptions } from "../indexer/types.js"; +import { type GraphBuildOptions } from "../graphs/types.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; -import { normalizePath, resolveFilePathFromRoot } from "../util.js"; +import { normalizePath, resolveFilePathFromRoot } from "../util/paths.js"; import { parseCacheModeOption, parseNonNegativeIntegerOption } from "./options.js"; export type GraphDeltaCommandContext = { diff --git a/src/cli/graphQueries.ts b/src/cli/graphQueries.ts index eba2b216..967b46c9 100644 --- a/src/cli/graphQueries.ts +++ b/src/cli/graphQueries.ts @@ -1,11 +1,8 @@ import path from "node:path"; -import { - buildProjectIndex as defaultBuildProjectIndex, - getApiSurface, - type BuildOptions, - type ProjectIndex, -} from "../indexer.js"; +import { buildProjectIndex as defaultBuildProjectIndex } from "../indexer/build-index.js"; +import { getApiSurface } from "../indexer/symbols.js"; +import { type BuildOptions, type ProjectIndex } from "../indexer/types.js"; import type { GraphAdjacencyIndex } from "../graphs/adjacency.js"; import { findDetailedCycles, @@ -14,10 +11,10 @@ import { getShortestPath, getUnresolvedImports, sortDetailedCycles, - type GraphBuildOptions, -} from "../graphs.js"; +} from "../graphs/queries.js"; +import { type GraphBuildOptions } from "../graphs/types.js"; import type { Graph } from "../types.js"; -import { assertFilePathWithinRoot } from "../util.js"; +import { assertFilePathWithinRoot } from "../util/paths.js"; import { parseOptionalNonNegativeIntegerOption } from "./options.js"; export type GraphQueryCommand = "deps" | "rdeps" | "path" | "cycles" | "unresolved" | "apisurface"; diff --git a/src/cli/grep.ts b/src/cli/grep.ts index 514805be..6676264e 100644 --- a/src/cli/grep.ts +++ b/src/cli/grep.ts @@ -1,5 +1,5 @@ -import { astGrep, textGrep } from "../graphs.js"; -import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { astGrep, textGrep } from "../graphs/grep.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseOptionalPositiveIntegerOption } from "./options.js"; export type GrepCommandContext = { diff --git a/src/cli/impact.ts b/src/cli/impact.ts index 966a4155..b19a42da 100644 --- a/src/cli/impact.ts +++ b/src/cli/impact.ts @@ -1,4 +1,4 @@ -import { buildProjectIndex } from "../indexer.js"; +import { buildProjectIndex } from "../indexer/build-index.js"; import type { BuildOptions } from "../indexer/types.js"; import { analyzeImpactFromDiff, @@ -8,15 +8,12 @@ import { type ImpactOptions, type ImpactReport, } from "../impact/index.js"; -import { - graphToMermaidSymbolsWithFiles, - type GraphBuildOptions, - type SymbolGraph, - type SymbolNodeKind, -} from "../graphs.js"; +import { graphToMermaidSymbolsWithFiles } from "../graphs/symbol-render.js"; +import { type GraphBuildOptions } from "../graphs/types.js"; +import { type SymbolGraph, type SymbolNodeKind } from "../graphs/symbol-graph.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; import type { Graph } from "../types.js"; -import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parseOptionalNonNegativeIntegerOption, diff --git a/src/cli/index.ts b/src/cli/index.ts index c8f5097d..453689ec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,8 +1,9 @@ import { performance } from "node:perf_hooks"; -import { buildProjectIndex, buildProjectIndexFromFiles, type BuildOptions, type BuildReport } from "../indexer.js"; -import type { GraphBuildOptions } from "../graphs.js"; +import { buildProjectIndex, buildProjectIndexFromFiles } from "../indexer/build-index.js"; +import { type BuildOptions, type BuildReport } from "../indexer/types.js"; +import { type GraphBuildOptions } from "../graphs/types.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; -import type { ProjectFileDiscoveryOptions } from "../util.js"; +import type { ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parseNonNegativeIntegerOption } from "./options.js"; type CommandTimingReport = { diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts index e46d0800..56a19088 100644 --- a/src/cli/inspect.ts +++ b/src/cli/inspect.ts @@ -1,14 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { - collectGraph, - findDetailedCycles, - getHotspots, - getUnresolvedImports, - sortDetailedCycles, - type GraphBuildOptions, -} from "../graphs.js"; -import { buildProjectIndexIncremental, type BuildReport } from "../indexer.js"; +import { collectGraph } from "../graph-builder.js"; +import { findDetailedCycles, getUnresolvedImports, sortDetailedCycles } from "../graphs/queries.js"; +import { getHotspots } from "../graphs/hotspots.js"; +import { type GraphBuildOptions } from "../graphs/types.js"; +import { buildProjectIndexIncremental } from "../indexer/build-index.js"; +import { type BuildReport } from "../indexer/types.js"; import { getNativeTreeSitterLoadError, getNativeTreeSitterSupportedLanguageIds, @@ -17,7 +14,7 @@ import { } from "../native/treeSitterNative.js"; import type { Graph } from "../types.js"; import { supportForFile } from "../languages.js"; -import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parsePositiveIntegerOption } from "./options.js"; type CacheMode = "off" | "memory" | "disk"; diff --git a/src/cli/navigation.ts b/src/cli/navigation.ts index 53b7a2c9..56a6b3b9 100644 --- a/src/cli/navigation.ts +++ b/src/cli/navigation.ts @@ -1,7 +1,9 @@ import path from "node:path"; -import { buildProjectIndex, findReferences, goToDefinition } from "../indexer.js"; +import { buildProjectIndex } from "../indexer/build-index.js"; +import { findReferences, goToDefinition } from "../indexer/navigation.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; -import { assertFilePathWithinRoot, type ProjectFileDiscoveryOptions } from "../util.js"; +import { assertFilePathWithinRoot } from "../util/paths.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parsePositiveIntegerOption } from "./options.js"; type CliProjectFileInput = diff --git a/src/cli/review.ts b/src/cli/review.ts index c7cfff19..1cf32ef0 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -2,9 +2,9 @@ import { performance } from "node:perf_hooks"; import { buildReviewReport, type ReviewBuildReport, type ReviewDepth } from "../review.js"; import type { CandidateTestFile } from "../impact/context.js"; import type { BuildReport } from "../indexer/types.js"; -import type { GraphBuildOptions } from "../graphs.js"; +import { type GraphBuildOptions } from "../graphs/types.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; -import type { ProjectFileDiscoveryOptions } from "../util.js"; +import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parseOptionalNonNegativeIntegerOption } from "./options.js"; type CommandTimingReport = { diff --git a/src/cli/sql.ts b/src/cli/sql.ts index bcf89b6b..38dfbd3c 100644 --- a/src/cli/sql.ts +++ b/src/cli/sql.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { queryGraphSqliteRaw } from "../sqlite.js"; -import { normalizePath, resolveFilePathFromRoot } from "../util.js"; +import { normalizePath, resolveFilePathFromRoot } from "../util/paths.js"; export type SqlCommandContext = { getOpt: (name: string) => string | undefined; diff --git a/src/config.ts b/src/config.ts index 8fa29c89..9b671345 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import type { ProjectFileDiscoveryOptions } from "./util.js"; +import { type ProjectFileDiscoveryOptions } from "./util/projectFiles.js"; export const CODEGRAPH_CONFIG_FILE = "codegraph.config.json"; diff --git a/src/documentLinks.ts b/src/documentLinks.ts index 02274d6d..e7732619 100644 --- a/src/documentLinks.ts +++ b/src/documentLinks.ts @@ -1,4 +1,4 @@ -import type { ModuleSpecifier } from "./util.js"; +import { type ModuleSpecifier } from "./util/specifiers.js"; import { extractAsciidocModuleSpecifiers } from "./documentLinks/asciidoc.js"; import { extractHtmlAttributeSpecifiers, extractHtmlInlineScriptSpecifiers } from "./documentLinks/html.js"; import { extractMarkdownModuleSpecifiers, extractMdxModuleSpecifiers } from "./documentLinks/markdown.js"; diff --git a/src/documentLinks/asciidoc.ts b/src/documentLinks/asciidoc.ts index 6e970834..c9008a73 100644 --- a/src/documentLinks/asciidoc.ts +++ b/src/documentLinks/asciidoc.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { ModuleSpecifier } from "../util.js"; +import { type ModuleSpecifier } from "../util/specifiers.js"; import { extractHtmlAttributeSpecifiers } from "./html.js"; import { dedupeModuleSpecifiers, normalizeLinkSpecifier } from "./shared.js"; diff --git a/src/documentLinks/html.ts b/src/documentLinks/html.ts index 833a7da1..120fc524 100644 --- a/src/documentLinks/html.ts +++ b/src/documentLinks/html.ts @@ -1,4 +1,4 @@ -import { extractJsTsSpecifiers, type ModuleSpecifier } from "../util.js"; +import { extractJsTsSpecifiers, type ModuleSpecifier } from "../util/specifiers.js"; import { dedupeModuleSpecifiers, markResolutionKind, normalizeLinkSpecifier } from "./shared.js"; const DEFAULT_HTML_TAG_ATTRS: Record = { diff --git a/src/documentLinks/markdown.ts b/src/documentLinks/markdown.ts index 9ccaed9b..69ace284 100644 --- a/src/documentLinks/markdown.ts +++ b/src/documentLinks/markdown.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { extractJsTsSpecifiers, type ModuleSpecifier } from "../util.js"; +import { extractJsTsSpecifiers, type ModuleSpecifier } from "../util/specifiers.js"; import { extractHtmlAttributeSpecifiers } from "./html.js"; import { dedupeModuleSpecifiers, diff --git a/src/documentLinks/rst.ts b/src/documentLinks/rst.ts index 7f8b7be2..be3572e6 100644 --- a/src/documentLinks/rst.ts +++ b/src/documentLinks/rst.ts @@ -1,9 +1,5 @@ -import type { ModuleSpecifier } from "../util.js"; -import { - dedupeModuleSpecifiers, - normalizeLinkSpecifier, - normalizeReferenceLabel, -} from "./shared.js"; +import { type ModuleSpecifier } from "../util/specifiers.js"; +import { dedupeModuleSpecifiers, normalizeLinkSpecifier, normalizeReferenceLabel } from "./shared.js"; export function extractRstModuleSpecifiers(source: string): ModuleSpecifier[] { const out: ModuleSpecifier[] = []; diff --git a/src/documentLinks/sfc.ts b/src/documentLinks/sfc.ts index e15a7134..b3caa8eb 100644 --- a/src/documentLinks/sfc.ts +++ b/src/documentLinks/sfc.ts @@ -1,4 +1,4 @@ -import { extractJsTsSpecifiers, type ModuleSpecifier } from "../util.js"; +import { extractJsTsSpecifiers, type ModuleSpecifier } from "../util/specifiers.js"; import { extractHtmlAttributeSpecifiers, extractHtmlInlineScriptSpecifiers } from "./html.js"; import { dedupeModuleSpecifiers, markResolutionKind, normalizeLinkSpecifier } from "./shared.js"; diff --git a/src/documentLinks/shared.ts b/src/documentLinks/shared.ts index a936af9e..51eb1a31 100644 --- a/src/documentLinks/shared.ts +++ b/src/documentLinks/shared.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { ModuleSpecifier } from "../util.js"; +import { type ModuleSpecifier } from "../util/specifiers.js"; const DOCUMENT_RELATIVE_EXTENSIONS = new Set([ ".md", diff --git a/src/graph-builder.ts b/src/graph-builder.ts index e62b16ea..573dd1c5 100644 --- a/src/graph-builder.ts +++ b/src/graph-builder.ts @@ -2,7 +2,8 @@ import path from "node:path"; import { isUnsupportedParserInputError } from "./languages/filePrep.js"; import type { Edge, Graph } from "./types.js"; -import { loadWorkspaceConfig, normalizeResolutionHints } from "./util.js"; +import { loadWorkspaceConfig } from "./util/workspace.js"; +import { normalizeResolutionHints } from "./util/paths.js"; import { mapLimit } from "./util/concurrency.js"; import { logWithLevel, type LogLevel } from "./logging.js"; import { isNativeRequiredUnavailableError, type NativeRuntimeMode } from "./native/treeSitterNative.js"; diff --git a/src/graph-edge-collector.ts b/src/graph-edge-collector.ts index 8d49983d..6064fb09 100644 --- a/src/graph-edge-collector.ts +++ b/src/graph-edge-collector.ts @@ -3,11 +3,9 @@ import { type JsLanguage } from "./jsFallback.js"; import { prepareSourceInput } from "./languages/filePrep.js"; import { type LanguageSupport } from "./languages.js"; import type { Edge } from "./types.js"; -import { - loadNearestTsconfigFor, - type WorkspaceConfig, - extractJsTsDynamicSpecifiers, -} from "./util.js"; +import { loadNearestTsconfigFor } from "./util/resolution.js"; +import { type WorkspaceConfig } from "./util/workspace.js"; +import { extractJsTsDynamicSpecifiers } from "./util/specifiers.js"; import { logWithLevel, type LogLevel } from "./logging.js"; import { graphOnlyLanguageSupportsImportAliases, diff --git a/src/graphs/angularjs.ts b/src/graphs/angularjs.ts index 3eedc959..4e97cf45 100644 --- a/src/graphs/angularjs.ts +++ b/src/graphs/angularjs.ts @@ -2,7 +2,8 @@ import fsp from "node:fs/promises"; import type { ParsedFileContext } from "../indexer/parse-context.js"; import { extractAngularJsReferences, extractAngularJsRegistrations } from "../frameworks/angularjs.js"; import type { Edge } from "../types.js"; -import { resolveSpecifier, type WorkspaceConfig } from "../util.js"; +import { resolveSpecifier } from "../util/resolution.js"; +import { type WorkspaceConfig } from "../util/workspace.js"; type AngularJsFileContext = { file: string; diff --git a/src/graphs/edgeResolution.ts b/src/graphs/edgeResolution.ts index 2241c2d4..666d6bc1 100644 --- a/src/graphs/edgeResolution.ts +++ b/src/graphs/edgeResolution.ts @@ -9,9 +9,9 @@ import { resolvePythonModule, resolveSpecifier, type MatchPathFn, - type ModuleSpecifier, - type WorkspaceConfig, -} from "../util.js"; +} from "../util/resolution.js"; +import { type ModuleSpecifier } from "../util/specifiers.js"; +import { type WorkspaceConfig } from "../util/workspace.js"; import { isGraphOnlyLanguage } from "../documentLinks.js"; type ResolvedSpecifierEdge = { @@ -114,7 +114,7 @@ export async function resolveModuleSpecifierEdges( } else if (context.support.id === "go" || context.support.id === "php") { to = await resolveImportSpecifierEdge(entry, context); } else if (["csharp", "ruby", "rust"].includes(context.support.id)) { - const { resolvePathLikeModule } = await import("../util.js"); + const { resolvePathLikeModule } = await import("../util/resolution.js"); const pathLike = await resolvePathLikeModule(context.projectRoot, entry.spec); to = pathLike ? edgeToResolvedFile(pathLike) : await resolveGenericSpecifier(entry, context, resolutionExtensions); } else { diff --git a/src/graphs/grep.ts b/src/graphs/grep.ts index d240e00d..a48f1e41 100644 --- a/src/graphs/grep.ts +++ b/src/graphs/grep.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { prepareSourceInput } from "../languages/filePrep.js"; import { logWithLevel } from "../logging.js"; import { getUnifiedQueryExecution } from "../native/treeSitterNative.js"; -import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util.js"; +import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; export type AstGrepHit = { file: string; diff --git a/src/graphs/specifiers.ts b/src/graphs/specifiers.ts index 7f43dc07..71e4d5c4 100644 --- a/src/graphs/specifiers.ts +++ b/src/graphs/specifiers.ts @@ -32,7 +32,8 @@ import { extractHtmlInlineScriptSpecifiers, isGraphOnlyLanguage, } from "../documentLinks.js"; -import { sliceText, unquote, extractJsTsSpecifiers, extractPythonSpecifiers, type ModuleSpecifier } from "../util.js"; +import { sliceText, unquote } from "../util/ast.js"; +import { extractJsTsSpecifiers, extractPythonSpecifiers, type ModuleSpecifier } from "../util/specifiers.js"; export type FallbackImportExtractionReason = "fast" | "js-fallback-unavailable" | "query-error" | "query-empty"; diff --git a/src/graphs/symbol-graph-detailed/ast.ts b/src/graphs/symbol-graph-detailed/ast.ts index d14d3c22..7e3866ef 100644 --- a/src/graphs/symbol-graph-detailed/ast.ts +++ b/src/graphs/symbol-graph-detailed/ast.ts @@ -1,7 +1,7 @@ import type { LanguageSupport } from "../../languages.js"; import type { SyntaxNodeLike } from "../../languages/types.js"; import type { SymbolDef } from "../../indexer/types.js"; -import { sliceText, unquote } from "../../util.js"; +import { sliceText, unquote } from "../../util/ast.js"; import { getMemberAccessParts, memberExpressionTypeFor, diff --git a/src/graphs/symbol-graph-detailed/edgePasses.ts b/src/graphs/symbol-graph-detailed/edgePasses.ts index 33d05bd4..3a430a75 100644 --- a/src/graphs/symbol-graph-detailed/edgePasses.ts +++ b/src/graphs/symbol-graph-detailed/edgePasses.ts @@ -1,7 +1,7 @@ import type { ModuleIndex, ProjectIndex, SymbolDef } from "../../indexer/types.js"; import type { LanguageSupport } from "../../languages.js"; import type { SyntaxNodeLike } from "../../languages/types.js"; -import { sliceText } from "../../util.js"; +import { sliceText } from "../../util/ast.js"; import { getMemberAccessParts } from "../../util/memberAccess.js"; import { defNodeId, nodeForDef, type SymbolGraph } from "../symbol-graph.js"; import type { DetailedClassNode, DetailedFunctionNode } from "./ast.js"; diff --git a/src/graphs/symbol-graph-detailed/memberChains.ts b/src/graphs/symbol-graph-detailed/memberChains.ts index ebd4d1ac..f7f82da8 100644 --- a/src/graphs/symbol-graph-detailed/memberChains.ts +++ b/src/graphs/symbol-graph-detailed/memberChains.ts @@ -1,7 +1,7 @@ import type { LanguageSupport } from "../../languages.js"; import type { SyntaxNodeLike } from "../../languages/types.js"; import type { SymbolDef } from "../../indexer/types.js"; -import { sliceText } from "../../util.js"; +import { sliceText } from "../../util/ast.js"; import { collectMemberAccessChain, memberAccessTraversalTypes, diff --git a/src/impact/analyzer.ts b/src/impact/analyzer.ts index 364a4ecc..0735f79a 100644 --- a/src/impact/analyzer.ts +++ b/src/impact/analyzer.ts @@ -1,5 +1,5 @@ import type { FileId } from "../types.js"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { compileTestPatterns, createIndexTestFileMatcher } from "./testPatterns.js"; import type { ChangedSymbol, ImpactItem, ImpactOptions, FileChange } from "./types.js"; import { createImpactIgnoreMatcher } from "./path.js"; diff --git a/src/impact/collect.ts b/src/impact/collect.ts index daad16aa..177e1124 100644 --- a/src/impact/collect.ts +++ b/src/impact/collect.ts @@ -1,4 +1,4 @@ -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { mapLimit } from "../util/concurrency.js"; import { analyzeImpact } from "./analyzer.js"; import { locateChangedSymbolsWithLines } from "./map.js"; diff --git a/src/impact/context.ts b/src/impact/context.ts index e4486d95..15cc302b 100644 --- a/src/impact/context.ts +++ b/src/impact/context.ts @@ -1,7 +1,7 @@ import type { FileId } from "../types.js"; -import type { ProjectIndex } from "../indexer.js"; -import { buildSymbolGraphDetailed } from "../graphs.js"; -import type { SymbolEdge } from "../graphs.js"; +import { type ProjectIndex } from "../indexer/types.js"; +import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; +import { type SymbolEdge } from "../graphs/symbol-graph.js"; import { buildGraphAdjacency, getForwardNeighbors, getReverseNeighbors } from "../graphs/adjacency.js"; import { createGraphFileResolver } from "./path.js"; import { compileTestPatterns, createIndexTestFileMatcher } from "./testPatterns.js"; diff --git a/src/impact/direct.ts b/src/impact/direct.ts index b413ca24..a82ac062 100644 --- a/src/impact/direct.ts +++ b/src/impact/direct.ts @@ -1,6 +1,6 @@ import type { FileId } from "../types.js"; -import type { ProjectIndex, SymbolDef } from "../indexer.js"; -import { findReferences } from "../indexer.js"; +import { type ProjectIndex, type SymbolDef } from "../indexer/types.js"; +import { findReferences } from "../indexer/navigation.js"; import { Semaphore } from "../util/concurrency.js"; import type { ChangedSymbol, ImpactItem, ImpactOptions, ImpactReason } from "./types.js"; import { calculateSeverity, selectStrongerImpactReason } from "./severity.js"; diff --git a/src/impact/index.ts b/src/impact/index.ts index 6231d809..e08db1af 100644 --- a/src/impact/index.ts +++ b/src/impact/index.ts @@ -1,4 +1,4 @@ -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import type { ImpactReport, CompactImpactReport, ImpactOptions } from "./types.js"; import { collectImpactAnalysis } from "./collect.js"; import { buildImpactReport } from "./report.js"; diff --git a/src/impact/map.ts b/src/impact/map.ts index 6065c470..f6f27e71 100644 --- a/src/impact/map.ts +++ b/src/impact/map.ts @@ -1,6 +1,6 @@ import type { FileId } from "../types.js"; -import type { ProjectIndex, SymbolDef, SymbolHandle } from "../indexer.js"; -import { ensureParsedContext } from "../indexer.js"; +import { type ProjectIndex, type SymbolDef, type SymbolHandle } from "../indexer/types.js"; +import { ensureParsedContext } from "../indexer/parse-context.js"; import { buildTrackedSymbolPositions, findLocalByStartPosition, diff --git a/src/impact/path.ts b/src/impact/path.ts index a269168e..46f94f90 100644 --- a/src/impact/path.ts +++ b/src/impact/path.ts @@ -1,7 +1,7 @@ import type { FileId } from "../types.js"; import type { FileChange } from "./types.js"; import pm from "picomatch"; -import { isFilePathWithinRoot, normalizePath, resolveFilePathFromRoot, toProjectRelativePath } from "../util.js"; +import { isFilePathWithinRoot, normalizePath, resolveFilePathFromRoot, toProjectRelativePath } from "../util/paths.js"; export function normalizeImpactFilePath(projectRoot: string, filePath: string): string { return normalizePath(resolveFilePathFromRoot(projectRoot, filePath)); @@ -40,7 +40,9 @@ export function createImpactIgnoreMatcher( projectRoot: string, ignoreGlobs: readonly string[], ): (filePath: string) => boolean { - const normalizedIgnoreGlobs = ignoreGlobs.map((globPattern) => globPattern.trim().replace(/\\/g, "/")).filter(Boolean); + const normalizedIgnoreGlobs = ignoreGlobs + .map((globPattern) => globPattern.trim().replace(/\\/g, "/")) + .filter(Boolean); if (!normalizedIgnoreGlobs.length) { return () => false; } diff --git a/src/impact/providers/base.ts b/src/impact/providers/base.ts index ee5ba684..732d7b93 100644 --- a/src/impact/providers/base.ts +++ b/src/impact/providers/base.ts @@ -1,7 +1,7 @@ import type { Diff, DiffProviderOptions } from "../types.js"; import { spawn } from "node:child_process"; import { parseUnifiedDiff, parseUnifiedDiffStreaming } from "../parse.js"; -import { gitDiffArgs } from "../../util.js"; +import { gitDiffArgs } from "../../util/git.js"; export interface DiffProvider { getDiff(opts: DiffProviderOptions): Promise; diff --git a/src/impact/report-suggestions.ts b/src/impact/report-suggestions.ts index c5b8e57c..d244c9a5 100644 --- a/src/impact/report-suggestions.ts +++ b/src/impact/report-suggestions.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { SymbolKind, type ProjectIndex, findReferences } from "../indexer.js"; -import { resolveFilePathFromRoot } from "../util.js"; +import { SymbolKind, type ProjectIndex } from "../indexer/types.js"; +import { findReferences } from "../indexer/navigation.js"; +import { resolveFilePathFromRoot } from "../util/paths.js"; import { mapLimit } from "../util/concurrency.js"; import { listCandidateTestFiles } from "./context.js"; import { collectHunkLineText, collectRemovedLines } from "./hunks.js"; diff --git a/src/impact/report.ts b/src/impact/report.ts index ae00400c..de5e028a 100644 --- a/src/impact/report.ts +++ b/src/impact/report.ts @@ -1,6 +1,6 @@ import type { FileId } from "../types.js"; import path from "node:path"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import type { FileChange, ChangedSymbol, @@ -17,8 +17,10 @@ import type { ImpactCycle, ImpactDiagnostics, } from "./types.js"; -import { buildSymbolGraphDetailed, findDetailedCycles } from "../graphs.js"; -import { discoverProjectFiles, normalizePath, resolveFilePathFromRoot } from "../util.js"; +import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; +import { findDetailedCycles } from "../graphs/queries.js"; +import { discoverProjectFiles } from "../util/projectFiles.js"; +import { normalizePath, resolveFilePathFromRoot } from "../util/paths.js"; import { newFileRangeForHunk } from "./hunks.js"; import { createGraphFileResolver, normalizeImpactFileChange, toImpactReportFilePath } from "./path.js"; import { buildCompactImpactReport } from "./reportCompact.js"; diff --git a/src/impact/reportCompact.ts b/src/impact/reportCompact.ts index 4484726d..b349e3a7 100644 --- a/src/impact/reportCompact.ts +++ b/src/impact/reportCompact.ts @@ -1,5 +1,5 @@ import type { FileId } from "../types.js"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { IMPACT_SCHEMA_VERSION } from "./types.js"; import type { ChangedSymbol, @@ -253,10 +253,7 @@ function buildCompactSurfaceArea( }; } -function buildCompactClusters( - clusters: ImpactCluster[], - fileId: (file: FileId) => number, -): CompactImpactCluster[] { +function buildCompactClusters(clusters: ImpactCluster[], fileId: (file: FileId) => number): CompactImpactCluster[] { return clusters.map((cluster) => ({ id: cluster.id, files: cluster.files.map((file) => fileId(file)), diff --git a/src/impact/reportFull.ts b/src/impact/reportFull.ts index e80320d9..43d2e65f 100644 --- a/src/impact/reportFull.ts +++ b/src/impact/reportFull.ts @@ -1,5 +1,5 @@ import type { FileId } from "../types.js"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { IMPACT_SCHEMA_VERSION } from "./types.js"; import type { ChangedSymbol, @@ -148,10 +148,7 @@ function buildFullTopImpacts( }; } -function buildFullCycles( - cycles: ImpactCycle[], - displayFile: (file: FileId) => FileId, -): Pick { +function buildFullCycles(cycles: ImpactCycle[], displayFile: (file: FileId) => FileId): Pick { if (!cycles.length) return {}; return { cycles: cycles.map((cycle) => ({ diff --git a/src/impact/severity.ts b/src/impact/severity.ts index ec05aeed..3e59332d 100644 --- a/src/impact/severity.ts +++ b/src/impact/severity.ts @@ -1,5 +1,5 @@ import type { FileId, Edge } from "../types.js"; -import type { ProjectIndex, Reference } from "../indexer.js"; +import { type ProjectIndex, type Reference } from "../indexer/types.js"; import type { ChangedSymbol, ImpactReason, SeverityWeights } from "./types.js"; import { DEFAULT_SEVERITY_WEIGHTS } from "./types.js"; diff --git a/src/impact/streaming.ts b/src/impact/streaming.ts index 4ef70fc7..c2b8f873 100644 --- a/src/impact/streaming.ts +++ b/src/impact/streaming.ts @@ -3,7 +3,7 @@ * Allows incremental results to be emitted as they're discovered */ -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { IMPACT_SCHEMA_VERSION, type ImpactOptions, @@ -14,7 +14,7 @@ import { } from "./types.js"; import { getDiff } from "./providers/base.js"; import { analyzeImpact } from "./analyzer.js"; -import { discoverProjectFiles, type ProjectFileInfo } from "../util.js"; +import { discoverProjectFiles, type ProjectFileInfo } from "../util/projectFiles.js"; import { buildImpactReport, newFileRangeForHunk } from "./report.js"; import { applyChangedFileSymbolMapping, diff --git a/src/impact/suggestions.ts b/src/impact/suggestions.ts index 84e27250..55596e94 100644 --- a/src/impact/suggestions.ts +++ b/src/impact/suggestions.ts @@ -1,9 +1,10 @@ import type { FileId, Range } from "../types.js"; -import type { ExportEntry, ImportBinding, ModuleIndex, ProjectIndex } from "../indexer.js"; -import { goToDefinition, ensureParsedContext } from "../indexer.js"; +import { type ExportEntry, type ImportBinding, type ModuleIndex, type ProjectIndex } from "../indexer/types.js"; +import { goToDefinition } from "../indexer/navigation.js"; +import { ensureParsedContext } from "../indexer/parse-context.js"; import type { LanguageSupport } from "../languages.js"; import type { SyntaxNodeLike, SyntaxTreeLike } from "../languages/types.js"; -import { normalizePath, resolveFilePathFromRoot, toProjectRelativePath } from "../util.js"; +import { normalizePath, resolveFilePathFromRoot, toProjectRelativePath } from "../util/paths.js"; import { collectChangedLines } from "./hunks.js"; import type { FileChange, ImpactOptions, ImpactSuggestion, ImpactSuggestionConfidence } from "./types.js"; diff --git a/src/impact/testPatterns.ts b/src/impact/testPatterns.ts index dd1bf33d..aad84da9 100644 --- a/src/impact/testPatterns.ts +++ b/src/impact/testPatterns.ts @@ -1,7 +1,7 @@ import path from "node:path"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import type { FileId } from "../types.js"; -import { normalizePath, toProjectRelativePath } from "../util.js"; +import { normalizePath, toProjectRelativePath } from "../util/paths.js"; const DEFAULT_TEST_PATTERNS: readonly RegExp[] = [ /(^|\/)__tests__(\/|$)/i, diff --git a/src/impact/transitive.ts b/src/impact/transitive.ts index 6ff41d70..4e6c3680 100644 --- a/src/impact/transitive.ts +++ b/src/impact/transitive.ts @@ -1,5 +1,5 @@ import type { FileId, Edge } from "../types.js"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { compileTestPatterns, createIndexTestFileMatcher } from "./testPatterns.js"; import type { FileChange, ImpactItem, ImpactOptions, ImpactReason } from "./types.js"; import { createImpactIgnoreMatcher } from "./path.js"; diff --git a/src/impact/types.ts b/src/impact/types.ts index 5d3f967b..2e7214f0 100644 --- a/src/impact/types.ts +++ b/src/impact/types.ts @@ -1,6 +1,6 @@ import type { FileId, Range } from "../types.js"; -import type { SymbolHandle, SymbolDef } from "../indexer.js"; -import type { ProjectFileInfo } from "../util.js"; +import { type SymbolHandle, type SymbolDef } from "../indexer/types.js"; +import { type ProjectFileInfo } from "../util/projectFiles.js"; // Diff parsing types export type Hunk = { diff --git a/src/indexer/build-cache/manifest.ts b/src/indexer/build-cache/manifest.ts index 81299aa8..d40a896f 100644 --- a/src/indexer/build-cache/manifest.ts +++ b/src/indexer/build-cache/manifest.ts @@ -8,13 +8,12 @@ import { logWithLevel, type LogLevel } from "../../logging.js"; import type { Edge } from "../../types.js"; import { DEFAULT_PROJECT_MANIFESTS, - assertFilePathWithinRoot, - getGitBlobHashes, - isFilePathWithinRoot, listProjectFiles, - stringifyUnknown, type ProjectFileDiscoveryOptions, -} from "../../util.js"; +} from "../../util/projectFiles.js"; +import { assertFilePathWithinRoot, isFilePathWithinRoot } from "../../util/paths.js"; +import { getGitBlobHashes } from "../../util/git.js"; +import { stringifyUnknown } from "../../util/ast.js"; import type { BuildOptions } from "../types.js"; import { cacheRoot, fileSignature } from "./module-cache.js"; import type { ManifestBuildOptions } from "./options.js"; diff --git a/src/indexer/build-cache/options.ts b/src/indexer/build-cache/options.ts index cf2f50b3..2956bb85 100644 --- a/src/indexer/build-cache/options.ts +++ b/src/indexer/build-cache/options.ts @@ -1,10 +1,7 @@ import path from "node:path"; import type { GraphBuildOptions } from "../../graphs/types.js"; -import { - normalizePath, - normalizeResolutionHints, - type ProjectFileDiscoveryOptions, -} from "../../util.js"; +import { normalizePath, normalizeResolutionHints } from "../../util/paths.js"; +import { type ProjectFileDiscoveryOptions } from "../../util/projectFiles.js"; import type { BuildOptions } from "../types.js"; export type ManifestBuildOptions = { @@ -36,12 +33,8 @@ function normalizeManifestBuildOptions(opts?: ManifestBuildOptions): ManifestBui function normalizeDiscoveryOptions(discovery?: ProjectFileDiscoveryOptions): ManifestBuildOptions["discovery"] { if (!discovery) return undefined; const normalizeGlob = (glob: string) => glob.trim().replace(/\\/g, "/"); - const includeGlobs = Array.from( - new Set((discovery.includeGlobs ?? []).map(normalizeGlob).filter(Boolean)), - ).sort(); - const ignoreGlobs = Array.from( - new Set((discovery.ignoreGlobs ?? []).map(normalizeGlob).filter(Boolean)), - ).sort(); + const includeGlobs = Array.from(new Set((discovery.includeGlobs ?? []).map(normalizeGlob).filter(Boolean))).sort(); + const ignoreGlobs = Array.from(new Set((discovery.ignoreGlobs ?? []).map(normalizeGlob).filter(Boolean))).sort(); const globRoot = discovery.globRoot ? normalizePath(path.resolve(discovery.globRoot)) : undefined; const gitignoreRoot = discovery.gitignoreRoot ? normalizePath(path.resolve(discovery.gitignoreRoot)) : undefined; const useGitignore = discovery.useGitignore ?? true; diff --git a/src/indexer/build-cache/reports.ts b/src/indexer/build-cache/reports.ts index 8b034b1a..3e54ff44 100644 --- a/src/indexer/build-cache/reports.ts +++ b/src/indexer/build-cache/reports.ts @@ -1,7 +1,7 @@ import { shouldAvoidJsFallbackForLanguage } from "../../native/treeSitterNative.js"; import type { FallbackImportExtractionEvent } from "../../graphs/specifiers.js"; import { logWithLevel, type LogLevel } from "../../logging.js"; -import { stringifyUnknown } from "../../util.js"; +import { stringifyUnknown } from "../../util/ast.js"; import type { BuildFileReport, BuildOptions, diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index 79c335a1..fab74a4d 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -2,24 +2,16 @@ import fs from "node:fs"; import path from "node:path"; import { performance } from "node:perf_hooks"; import { supportForFile, type LanguageSupport } from "../languages.js"; -import { - loadWorkspaceConfig, - listProjectFiles, - discoverProjectFiles, - getGitHead, - isGitRepo, - getGitBlobHashes, - listChangedFiles, - clearImportResolutionCaches, - stringifyUnknown, - assertFilePathWithinRoot, - normalizePath, - resolveSpecifier, - resolveWorkspacePackage, -} from "../util.js"; +import { loadWorkspaceConfig, resolveWorkspacePackage } from "../util/workspace.js"; +import { listProjectFiles, discoverProjectFiles } from "../util/projectFiles.js"; +import { getGitHead, isGitRepo, getGitBlobHashes, listChangedFiles } from "../util/git.js"; +import { clearImportResolutionCaches, resolveSpecifier } from "../util/resolution.js"; +import { stringifyUnknown } from "../util/ast.js"; +import { assertFilePathWithinRoot, normalizePath } from "../util/paths.js"; import { mapLimit } from "../util/concurrency.js"; import { logWithLevel, type LogLevel } from "../logging.js"; -import { collectGraph, collectEdgesForFile } from "../graphs.js"; +import { collectGraph } from "../graph-builder.js"; +import { collectEdgesForFile } from "../graph-edge-collector.js"; import { buildGraphAdjacency } from "../graphs/adjacency.js"; import type { FallbackImportExtractionEvent } from "../graphs/specifiers.js"; import type { GraphCacheEntry, GraphBuildOptions } from "../graphs/types.js"; @@ -303,7 +295,7 @@ async function resolveCrossModuleSymbolExports( ): Promise { if (!support.supportsCrossModuleSymbols) return; if (support.id !== "ts" && support.id !== "js") return; - const { matchPath } = await import("../util.js").then((mod) => mod.loadNearestTsconfigFor(file, logLevel)); + const { matchPath } = await import("../util/resolution.js").then((mod) => mod.loadNearestTsconfigFor(file, logLevel)); for (const entry of mod.exports) { if (entry.type !== "reexport" && entry.type !== "exportStar" && entry.type !== "namespaceReexport") { continue; diff --git a/src/indexer/imports.ts b/src/indexer/imports.ts index 801c98a3..73a1e6c6 100644 --- a/src/indexer/imports.ts +++ b/src/indexer/imports.ts @@ -5,11 +5,8 @@ import { type JsSyntaxTree, } from "../jsFallback.js"; import { prepareSourceInput } from "../languages/filePrep.js"; -import { - loadNearestTsconfigFor, - loadWorkspaceConfig, - resolveImportSpecifier, -} from "../util.js"; +import { loadNearestTsconfigFor, resolveImportSpecifier } from "../util/resolution.js"; +import { loadWorkspaceConfig } from "../util/workspace.js"; import { logWithLevel, type LogLevel } from "../logging.js"; import { type FallbackImportExtractionEvent, type FallbackImportExtractionReason } from "../graphs/specifiers.js"; import type { GraphBuildOptions } from "../graphs/types.js"; diff --git a/src/indexer/imports/graphOnly.ts b/src/indexer/imports/graphOnly.ts index 25694a09..6f27061b 100644 --- a/src/indexer/imports/graphOnly.ts +++ b/src/indexer/imports/graphOnly.ts @@ -5,7 +5,8 @@ import { } from "../../documentLinks.js"; import type { GraphBuildOptions } from "../../graphs/types.js"; import type { LogLevel } from "../../logging.js"; -import { getGraphOnlyResolutionExtensions, loadNearestTsconfigFor, loadWorkspaceConfig, resolveSpecifier } from "../../util.js"; +import { getGraphOnlyResolutionExtensions, loadNearestTsconfigFor, resolveSpecifier } from "../../util/resolution.js"; +import { loadWorkspaceConfig } from "../../util/workspace.js"; import type { ImportBinding } from "../types.js"; export type GraphOnlyImportExtractionContext = { @@ -53,7 +54,8 @@ export async function collectGraphOnlyImports(context: GraphOnlyImportExtraction } const from = entry.raw ?? entry.spec; - const normalizedResolved = typeof resolved === "string" ? resolved.replace(/\\/g, "/") : { ...resolved, external: from }; + const normalizedResolved = + typeof resolved === "string" ? resolved.replace(/\\/g, "/") : { ...resolved, external: from }; bindings.push({ kind: "star", from, diff --git a/src/indexer/imports/jsFallback.ts b/src/indexer/imports/jsFallback.ts index b8242372..a9cc1047 100644 --- a/src/indexer/imports/jsFallback.ts +++ b/src/indexer/imports/jsFallback.ts @@ -1,4 +1,4 @@ -import { stripJsLikeComments } from "../../util.js"; +import { stripJsLikeComments } from "../../util/comments.js"; import type { ImportBindingSink, ImportResolver } from "./context.js"; export type JsTextImportExtractionContext = ImportBindingSink & { diff --git a/src/indexer/imports/languageSpecific.ts b/src/indexer/imports/languageSpecific.ts index befd72a8..4e32cbe6 100644 --- a/src/indexer/imports/languageSpecific.ts +++ b/src/indexer/imports/languageSpecific.ts @@ -6,7 +6,7 @@ import { parsePhpImportStatement, parseRustImportStatement, } from "../../languages/importStatementParsers.js"; -import { getPhpComposerImplicitFiles } from "../../util.js"; +import { getPhpComposerImplicitFiles } from "../../util/resolution.js"; import type { ImportBinding } from "../types.js"; import type { ImportBindingSink, ImportResolver, ResolvedImportTarget } from "./context.js"; @@ -469,7 +469,8 @@ export function appendImplicitImportBinding( } else { const parts = from.split("."); const imported = parts[parts.length - 1]; - if (imported) context.pushBinding({ kind: "named", local: alias ?? imported, imported, from, resolved, typeOnly }); + if (imported) + context.pushBinding({ kind: "named", local: alias ?? imported, imported, from, resolved, typeOnly }); } } else if (context.languageId === "swift") { const parts = from.split("."); diff --git a/src/indexer/imports/nativeCaptures.ts b/src/indexer/imports/nativeCaptures.ts index 922837dc..6b3e6a2e 100644 --- a/src/indexer/imports/nativeCaptures.ts +++ b/src/indexer/imports/nativeCaptures.ts @@ -1,7 +1,7 @@ import type { JsSyntaxTree } from "../../jsFallback.js"; import { capturesByName, capturesNamed, rangeFromNativeCapture } from "../../native/queryResults.js"; import type { NativeCapture, NativeMatch } from "../../native/treeSitterNative.js"; -import { sliceText, unquote } from "../../util.js"; +import { sliceText, unquote } from "../../util/ast.js"; import { parseGoImportAlias } from "../shared.js"; import type { ImportBinding } from "../types.js"; import type { ImportResolver, ResolvedImportTarget } from "./context.js"; @@ -71,17 +71,11 @@ async function pushTreeObjectPatternBindings( if (!from) return; for (const pattern of patterns) { const patternRange = rangeFromNativeCapture(pattern); - const patternNode = tree.rootNode.descendantForIndex( - patternRange.start.index ?? 0, - patternRange.end.index ?? 0, - ); + const patternNode = tree.rootNode.descendantForIndex(patternRange.start.index ?? 0, patternRange.end.index ?? 0); if (patternNode.type !== "object_pattern") continue; for (const child of patternNode.namedChildren) { - if ( - child.type === "shorthand_property_identifier" || - child.type === "shorthand_property_identifier_pattern" - ) { + if (child.type === "shorthand_property_identifier" || child.type === "shorthand_property_identifier_pattern") { const name = sliceText(child, context.source); const resolved = await context.resolveFrom(from); context.pushBinding({ diff --git a/src/indexer/imports/python.ts b/src/indexer/imports/python.ts index ab26abfb..b73eea6b 100644 --- a/src/indexer/imports/python.ts +++ b/src/indexer/imports/python.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { resolvePythonModule, stripPythonCommentsAndStrings } from "../../util.js"; +import { resolvePythonModule } from "../../util/resolution.js"; +import { stripPythonCommentsAndStrings } from "../../util/comments.js"; import type { ImportBindingSink, ResolvedImportTarget } from "./context.js"; export type PythonImportExtractionContext = ImportBindingSink & { @@ -88,11 +89,7 @@ async function pushNamedImport( }); } -async function pushDefaultImport( - context: PythonImportExtractionContext, - dotted: string, - local: string, -): Promise { +async function pushDefaultImport(context: PythonImportExtractionContext, dotted: string, local: string): Promise { const resolved = await resolvePythonModule(context.projectRoot, context.file, dotted, 0); context.pushBinding({ kind: "namespace", diff --git a/src/indexer/locals-and-exports.ts b/src/indexer/locals-and-exports.ts index ff319ac2..2be8c7f8 100644 --- a/src/indexer/locals-and-exports.ts +++ b/src/indexer/locals-and-exports.ts @@ -11,7 +11,8 @@ import { type NativeQueryResults, type NativeRuntimeMode, } from "../native/treeSitterNative.js"; -import { maskJsLikeCommentsAndStrings, sliceText, toRange, unquote } from "../util.js"; +import { maskJsLikeCommentsAndStrings } from "../util/comments.js"; +import { sliceText, toRange, unquote } from "../util/ast.js"; import { bindingKindToSymbolKind } from "./declarations.js"; import { buildScopeIndexFromSource } from "./scope.js"; import { QUERY_DRIVEN_LOCALS_LANGUAGES } from "./shared.js"; diff --git a/src/indexer/navigation-goto.ts b/src/indexer/navigation-goto.ts index 0770967f..f3cdd3d5 100644 --- a/src/indexer/navigation-goto.ts +++ b/src/indexer/navigation-goto.ts @@ -1,6 +1,6 @@ import type { LanguageSupport } from "../languages.js"; import type { SyntaxNodeLike } from "../languages/types.js"; -import { sliceText } from "../util.js"; +import { sliceText } from "../util/ast.js"; import { getMemberAccessParts, getNavigationExpressionProperty, diff --git a/src/indexer/navigation-php.ts b/src/indexer/navigation-php.ts index 5fae967e..f3ad37d3 100644 --- a/src/indexer/navigation-php.ts +++ b/src/indexer/navigation-php.ts @@ -1,6 +1,6 @@ import type { SyntaxNodeLike, SyntaxTreeLike } from "../languages/types.js"; import type { Range } from "../types.js"; -import { sliceText } from "../util.js"; +import { sliceText } from "../util/ast.js"; function readPhpNamespaceName(namespaceNode: SyntaxNodeLike, source: string): string | null { const namespaceName = diff --git a/src/indexer/navigation-references.ts b/src/indexer/navigation-references.ts index aff8bb2b..4f81d2e2 100644 --- a/src/indexer/navigation-references.ts +++ b/src/indexer/navigation-references.ts @@ -1,7 +1,7 @@ import type { LanguageSupport } from "../languages.js"; import type { JsLanguage, SyntaxNodeLike, SyntaxTreeLike } from "../languages/types.js"; import type { Range } from "../types.js"; -import { sliceText, toRange } from "../util.js"; +import { sliceText, toRange } from "../util/ast.js"; import { ensureParsedContext } from "./parse-context.js"; import { sameDef } from "./reference-context.js"; import { readPhpNamespaceFromRange } from "./navigation-php.js"; diff --git a/src/indexer/navigation.ts b/src/indexer/navigation.ts index 19059f73..41b818ea 100644 --- a/src/indexer/navigation.ts +++ b/src/indexer/navigation.ts @@ -26,7 +26,8 @@ import { extractEnclosingBlock, extractLineContext, rangeContains, sameDef } fro import { DEFAULT_REF_CONTEXT_LINES } from "./shared.js"; import { type ScopeIndex } from "./scope.js"; import { type FileId, type Range } from "../types.js"; -import { resolveImportSpecifier, sliceText, toRange } from "../util.js"; +import { resolveImportSpecifier } from "../util/resolution.js"; +import { sliceText, toRange } from "../util/ast.js"; import { getMemberAccessParts, isMemberAccessNode, diff --git a/src/indexer/parse-context.ts b/src/indexer/parse-context.ts index 084e1133..ab2d53a6 100644 --- a/src/indexer/parse-context.ts +++ b/src/indexer/parse-context.ts @@ -8,7 +8,7 @@ import { } from "../native/treeSitterNative.js"; import type { NativeFallbackReason } from "../native/contracts.js"; import { ProjectedSyntaxTree } from "../native/projectedTree.js"; -import { stringifyUnknown } from "../util.js"; +import { stringifyUnknown } from "../util/ast.js"; import type { LanguageSupport } from "../languages.js"; import type { JsLanguage, SyntaxTreeLike } from "../languages/types.js"; diff --git a/src/indexer/reference-context.ts b/src/indexer/reference-context.ts index af465b8b..09e1ee77 100644 --- a/src/indexer/reference-context.ts +++ b/src/indexer/reference-context.ts @@ -1,4 +1,4 @@ -import { sliceText } from "../util.js"; +import { sliceText } from "../util/ast.js"; import type { LanguageSupport } from "../languages.js"; import type { SyntaxNodeLike, SyntaxTreeLike } from "../languages/types.js"; import type { Range } from "../types.js"; diff --git a/src/indexer/scope.ts b/src/indexer/scope.ts index d3bfa584..3f97701b 100644 --- a/src/indexer/scope.ts +++ b/src/indexer/scope.ts @@ -1,5 +1,5 @@ import { parseWithJsLanguage } from "../jsFallback.js"; -import { sliceText, toRange } from "../util.js"; +import { sliceText, toRange } from "../util/ast.js"; import { getNativeSyntaxTreeExecution, type NativeRuntimeMode } from "../native/treeSitterNative.js"; import { ProjectedSyntaxTree } from "../native/projectedTree.js"; import { declarationKindToBindingKind } from "./declarations.js"; diff --git a/src/indexer/shared.ts b/src/indexer/shared.ts index 51577698..27ac98da 100644 --- a/src/indexer/shared.ts +++ b/src/indexer/shared.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { normalizePath } from "../util.js"; +import { normalizePath } from "../util/paths.js"; import type { Edge } from "../types.js"; export const DEFAULT_REF_CONTEXT_LINES = 5; diff --git a/src/indexer/types.ts b/src/indexer/types.ts index cacb9e35..34e2c34d 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -6,7 +6,7 @@ import type { NativeFallbackReason, NativeRuntimeMode } from "../native/contract import type { ScopeIndex } from "./scope-types.js"; import type { ParsedFileContext } from "./parse-context.js"; import type { Edge, FileId, Graph, Range } from "../types.js"; -import type { ProjectFileDiscoveryOptions, ProjectFileInfo } from "../util.js"; +import { type ProjectFileDiscoveryOptions, type ProjectFileInfo } from "../util/projectFiles.js"; import type { ImportBinding } from "./import-types.js"; export type { ImportBinding } from "./import-types.js"; diff --git a/src/languages/importStatementParsers.ts b/src/languages/importStatementParsers.ts index 36bf88af..08181372 100644 --- a/src/languages/importStatementParsers.ts +++ b/src/languages/importStatementParsers.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { isAbsoluteFilePath, normalizePath } from "../util.js"; +import { isAbsoluteFilePath, normalizePath } from "../util/paths.js"; export type ParsedRustImportStatement = | { diff --git a/src/mcp/security.ts b/src/mcp/security.ts index ee0f0e61..9470765c 100644 --- a/src/mcp/security.ts +++ b/src/mcp/security.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "../util.js"; +import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "../util/paths.js"; export function resolveArtifactSqlitePathCandidate(root: string, artifactPath: string): string { const resolved = path.isAbsolute(artifactPath) ? artifactPath : path.resolve(root, artifactPath); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 908b2487..9fddd1a8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,11 +19,11 @@ import { explainCodegraphTargetWithSession } from "../agent/explain.js"; import type { AgentExplanation, AgentExplanationReference } from "../agent/explain.js"; import { searchCodegraphWithSession } from "../agent/search.js"; import type { AgentSearchMode, AgentSearchResponse } from "../agent/search.js"; -import { getDependencies, getReverseDependencies, getShortestPath } from "../graphs.js"; -import { findReferences, goToDefinition } from "../indexer.js"; +import { getDependencies, getReverseDependencies, getShortestPath } from "../graphs/queries.js"; +import { findReferences, goToDefinition } from "../indexer/navigation.js"; import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../review.js"; import { queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; -import { normalizePath, toProjectRelativePath } from "../util.js"; +import { normalizePath, toProjectRelativePath } from "../util/paths.js"; import { createAgentSession } from "../agent/session.js"; import type { AgentSession } from "../agent/session.js"; import { diff --git a/src/native/execution.ts b/src/native/execution.ts index 9823c47a..19a62453 100644 --- a/src/native/execution.ts +++ b/src/native/execution.ts @@ -1,5 +1,5 @@ import type { LanguageSupport } from "../languages.js"; -import { stringifyUnknown } from "../util.js"; +import { stringifyUnknown } from "../util/ast.js"; import type { CompactImportsExecution, NativeBindingState, diff --git a/src/native/nativeBackendReport.ts b/src/native/nativeBackendReport.ts index f3a92176..b5f21e77 100644 --- a/src/native/nativeBackendReport.ts +++ b/src/native/nativeBackendReport.ts @@ -11,7 +11,7 @@ import type { NativeBackendFallbackReason, NativeBackendLanguageReport, } from "../indexer/types.js"; -import { stringifyUnknown } from "../util.js"; +import { stringifyUnknown } from "../util/ast.js"; export type NativeBackendOutcome = { usedNative: boolean; diff --git a/src/native/runtime.ts b/src/native/runtime.ts index af13680e..133744ce 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { stringifyUnknown } from "../util.js"; +import { stringifyUnknown } from "../util/ast.js"; import { loadNativeBinding } from "./bindingLoader.js"; import type { NativeBinding, NativeBindingState, NativeRuntimeMode } from "./contracts.js"; diff --git a/src/presets.ts b/src/presets.ts index 390f200c..189ec817 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -8,7 +8,7 @@ * - production: Maximum accuracy for production analysis */ -import type { BuildOptions } from "./indexer.js"; +import { type BuildOptions } from "./indexer/types.js"; import type { ImpactOptions } from "./impact/types.js"; export type PresetName = "code-review" | "ci-fast" | "development" | "production"; diff --git a/src/query/parser.ts b/src/query/parser.ts index 0a2faa0e..b4507385 100644 --- a/src/query/parser.ts +++ b/src/query/parser.ts @@ -1,4 +1,4 @@ -import type { SymbolNodeKind } from "../graphs.js"; +import { type SymbolNodeKind } from "../graphs/symbol-graph.js"; export type SymbolQuery = { text?: string; diff --git a/src/query/symbols.ts b/src/query/symbols.ts index c37cd6a5..e4f6978e 100644 --- a/src/query/symbols.ts +++ b/src/query/symbols.ts @@ -1,4 +1,4 @@ -import type { SymbolGraph, SymbolNode, SymbolEdge } from "../graphs.js"; +import { type SymbolGraph, type SymbolNode, type SymbolEdge } from "../graphs/symbol-graph.js"; import type { SymbolQuery } from "./parser.js"; const includesFolded = (value: string | undefined, needle: string): boolean => { diff --git a/src/review.ts b/src/review.ts index 6d495a89..6d118bb9 100644 --- a/src/review.ts +++ b/src/review.ts @@ -1,15 +1,12 @@ import { performance } from "node:perf_hooks"; import type { Edge, FileId } from "./types.js"; -import { - buildProjectIndexIncremental, - type BuildReport, - type IncrementalBuildOptions, - type ProjectIndex, -} from "./indexer.js"; +import { buildProjectIndexIncremental } from "./indexer/build-index.js"; +import { type BuildReport, type IncrementalBuildOptions, type ProjectIndex } from "./indexer/types.js"; import type { GraphBuildOptions } from "./graphs/types.js"; import type { FileChange, Hunk } from "./impact/types.js"; import type { CandidateTestFile } from "./impact/context.js"; -import { fileExists, discoverProjectFiles, type ProjectFileInfo } from "./util.js"; +import { fileExists } from "./util/workspace.js"; +import { discoverProjectFiles, type ProjectFileInfo } from "./util/projectFiles.js"; import type { SqlReviewContext } from "./sql/review.js"; import { collectReviewCandidateTests } from "./review/candidates.js"; import { collectReviewChanges } from "./review/changes.js"; diff --git a/src/review/candidates.ts b/src/review/candidates.ts index 657cdb74..22afea2c 100644 --- a/src/review/candidates.ts +++ b/src/review/candidates.ts @@ -1,8 +1,8 @@ import { performance } from "node:perf_hooks"; import { listCandidateTestFiles, type CandidateTestFile } from "../impact/context.js"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import type { FileId } from "../types.js"; -import { normalizePath, toProjectRelativePath } from "../util.js"; +import { normalizePath, toProjectRelativePath } from "../util/paths.js"; import type { ReviewOptions, ReviewTimingReport } from "../review.js"; import { listDirectDeletedFileTestImporters } from "./deleted.js"; diff --git a/src/review/changes.ts b/src/review/changes.ts index 7ae28dd0..b07f047d 100644 --- a/src/review/changes.ts +++ b/src/review/changes.ts @@ -3,7 +3,8 @@ import { createImpactIgnoreMatcher } from "../impact/path.js"; import { parseUnifiedDiff } from "../impact/parse.js"; import type { FileChange, Hunk } from "../impact/types.js"; import type { ReviewOptions, ReviewTimingReport } from "../review.js"; -import { assertFilePathWithinRoot, getUnifiedDiff, listChangedFiles } from "../util.js"; +import { assertFilePathWithinRoot } from "../util/paths.js"; +import { getUnifiedDiff, listChangedFiles } from "../util/git.js"; export type ReviewChangeCollection = { changedFiles: Set; diff --git a/src/review/deleted.ts b/src/review/deleted.ts index 88c22963..1ae1e357 100644 --- a/src/review/deleted.ts +++ b/src/review/deleted.ts @@ -5,25 +5,18 @@ import { promisify } from "node:util"; import type { CandidateTestFile } from "../impact/context.js"; import { compileTestPatterns, createIndexTestFileMatcher } from "../impact/testPatterns.js"; import type { FileChange } from "../impact/types.js"; -import { - collectImportsForFile, - collectLocalsAndExportsFromSource, - type ExportEntry, - type ImportBinding, - type ModuleIndex, - type ProjectIndex, -} from "../indexer.js"; +import { collectImportsForFile } from "../indexer/imports.js"; +import { collectLocalsAndExportsFromSource } from "../indexer/locals-and-exports.js"; +import { type ExportEntry, type ImportBinding, type ModuleIndex, type ProjectIndex } from "../indexer/types.js"; import { supportForFile } from "../languages.js"; import type { Edge, FileId } from "../types.js"; +import { listResolutionCandidates, loadNearestTsconfigFor } from "../util/resolution.js"; import { - listResolutionCandidates, listWorkspacePackageResolutionCandidates, - loadNearestTsconfigFor, loadWorkspaceConfig, - normalizePath, - toProjectRelativePath, type WorkspaceConfig, -} from "../util.js"; +} from "../util/workspace.js"; +import { normalizePath, toProjectRelativePath } from "../util/paths.js"; import type { GraphBuildOptions } from "../graphs/types.js"; const execFileAsync = promisify(execFile); diff --git a/src/review/report.ts b/src/review/report.ts index 65d1f676..95424fd8 100644 --- a/src/review/report.ts +++ b/src/review/report.ts @@ -1,9 +1,10 @@ import path from "node:path"; import type { CandidateTestFile } from "../impact/context.js"; -import type { ProjectIndex } from "../indexer.js"; +import { type ProjectIndex } from "../indexer/types.js"; import { collectSqlReviewContext, type SqlReviewContext } from "../sql/review.js"; import type { Edge, FileId } from "../types.js"; -import { normalizePath, type ProjectFileInfo } from "../util.js"; +import { normalizePath } from "../util/paths.js"; +import { type ProjectFileInfo } from "../util/projectFiles.js"; import type { ReviewDiagnostics, ReviewOptions, ReviewReport } from "../review.js"; import { collectDeletedImporterEdges, diff --git a/src/review/summaries.ts b/src/review/summaries.ts index 0628f270..d104e32f 100644 --- a/src/review/summaries.ts +++ b/src/review/summaries.ts @@ -2,19 +2,14 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { performance } from "node:perf_hooks"; import { isSymbolHandleExported } from "../indexer/declarations.js"; -import { - findReferences, - type ExportEntry, - type ModuleIndex, - type ProjectIndex, - type SymbolDef, - symbolId, -} from "../indexer.js"; +import { findReferences } from "../indexer/navigation.js"; +import { type ExportEntry, type ModuleIndex, type ProjectIndex, type SymbolDef } from "../indexer/types.js"; +import { symbolId } from "../indexer/symbols.js"; import { locateChangedSymbolsWithLines, mapChangedLinesToSymbols } from "../impact/map.js"; import type { Hunk } from "../impact/types.js"; import type { FileId, Range } from "../types.js"; import { mapLimit } from "../util/concurrency.js"; -import { normalizePath, toProjectRelativePath } from "../util.js"; +import { normalizePath, toProjectRelativePath } from "../util/paths.js"; import type { ReviewDiagnostics, ReviewTimingReport } from "../review.js"; import type { DeletedFileSnapshot } from "./deleted.js"; import { isRiskRelevantSymbolMappingFile } from "./risk.js"; diff --git a/src/session.ts b/src/session.ts index 838ddb59..2099d418 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4,8 +4,15 @@ */ import path from "node:path"; -import type { ProjectIndex, BuildOptions, GoToResult, Reference, SymbolDef } from "./indexer.js"; -import { buildProjectIndex, buildProjectIndexIncremental, findReferences, goToDefinition } from "./indexer.js"; +import { + type ProjectIndex, + type BuildOptions, + type GoToResult, + type Reference, + type SymbolDef, +} from "./indexer/types.js"; +import { buildProjectIndex, buildProjectIndexIncremental } from "./indexer/build-index.js"; +import { findReferences, goToDefinition } from "./indexer/navigation.js"; import { analyzeImpactFromDiff, type ImpactOptions, @@ -15,7 +22,7 @@ import { } from "./impact/index.js"; import { analyzeImpactStreaming, type ImpactStreamChunk } from "./impact/streaming.js"; import { getSessionPreset, mergePreset, type PresetName } from "./presets.js"; -import { assertFilePathWithinRoot } from "./util.js"; +import { assertFilePathWithinRoot } from "./util/paths.js"; export type SessionOptions = { /** Project root directory */ diff --git a/src/sqlite/types.ts b/src/sqlite/types.ts index f62741ce..ea8a5430 100644 --- a/src/sqlite/types.ts +++ b/src/sqlite/types.ts @@ -1,5 +1,5 @@ import type { Graph } from "../types.js"; -import type { SymbolGraph } from "../graphs.js"; +import { type SymbolGraph } from "../graphs/symbol-graph.js"; export type SqliteGraphOptions = { fileGraph: Graph; diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index 3387b146..5162f59b 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -1,4 +1,4 @@ -import type { SymbolGraph, SymbolNode } from "../graphs.js"; +import { type SymbolGraph, type SymbolNode } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import type { SqliteDatabase } from "../sqlite-driver.js"; import type { SqliteGraphOptions, SqliteGraphUpdateOptions } from "./types.js"; diff --git a/src/triples.ts b/src/triples.ts index ceefd965..60f907fb 100644 --- a/src/triples.ts +++ b/src/triples.ts @@ -1,5 +1,5 @@ import type { Graph } from "./types.js"; -import type { SymbolGraph, SymbolNode, SymbolNodeKind } from "./graphs.js"; +import { type SymbolGraph, type SymbolNode, type SymbolNodeKind } from "./graphs/symbol-graph.js"; export type TripleNode = | { diff --git a/src/util/lazySymbols.ts b/src/util/lazySymbols.ts index 147088d5..b57576a7 100644 --- a/src/util/lazySymbols.ts +++ b/src/util/lazySymbols.ts @@ -8,9 +8,9 @@ import picomatch from "picomatch"; import { supportForFile } from "../languages.js"; -import type { ImportBinding, ModuleIndex, SymbolDef } from "../indexer.js"; +import { type ImportBinding, type ModuleIndex, type SymbolDef } from "../indexer/types.js"; import type { FileId } from "../types.js"; -import { collectLocalsAndExportsFromSource } from "../indexer.js"; +import { collectLocalsAndExportsFromSource } from "../indexer/locals-and-exports.js"; import { parsePreparedFileContext } from "../indexer/parse-context.js"; /** @@ -204,10 +204,7 @@ export class LazyProjectIndex { ? Math.max(0, Math.floor(options.maxCached)) : 100; this.preloadStrategy = options?.preloadStrategy ?? "none"; - this.preloadMatcher = - options?.preloadPatterns?.length - ? picomatch(options.preloadPatterns, { dot: true }) - : null; + this.preloadMatcher = options?.preloadPatterns?.length ? picomatch(options.preloadPatterns, { dot: true }) : null; } /** diff --git a/src/util/memberAccess.ts b/src/util/memberAccess.ts index 3dbdbb65..86b8e6e0 100644 --- a/src/util/memberAccess.ts +++ b/src/util/memberAccess.ts @@ -1,6 +1,6 @@ import type { LanguageSupport } from "../languages.js"; import type { SyntaxNodeLike } from "../languages/types.js"; -import { sliceText, unquote } from "../util.js"; +import { sliceText, unquote } from "./ast.js"; export type MemberAccessParts = { object: SyntaxNodeLike | null; diff --git a/src/util/symbolHash.ts b/src/util/symbolHash.ts index 9527af6d..ee99d80f 100644 --- a/src/util/symbolHash.ts +++ b/src/util/symbolHash.ts @@ -7,7 +7,7 @@ */ import crypto from "node:crypto"; -import type { ExportEntry, SymbolDef } from "../indexer.js"; +import { type ExportEntry, type SymbolDef } from "../indexer/types.js"; import type { Edge } from "../types.js"; /** diff --git a/tests/impact-context-large.test.ts b/tests/impact-context-large.test.ts index 9ea26f19..dc8f5104 100644 --- a/tests/impact-context-large.test.ts +++ b/tests/impact-context-large.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; -import type { ModuleIndex, ProjectIndex } from "../src/indexer.js"; +import type { ModuleIndex, ProjectIndex } from "../src/indexer/types.js"; type MockSymbolNode = { id: string; @@ -54,7 +54,7 @@ const buildMockGraph = (size = 500): MockSymbolGraph => { let cachedMockGraph: MockSymbolGraph | undefined; -vi.mock("../src/graphs.js", () => { +vi.mock("../src/graphs/symbol-graph-detailed.js", () => { const graph = buildMockGraph(600); cachedMockGraph = graph; return { diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts index abdab00d..b8319513 100644 --- a/tests/package-metadata.test.ts +++ b/tests/package-metadata.test.ts @@ -274,6 +274,20 @@ describe("package metadata", () => { expect(offenders).toEqual([]); }); + it("keeps implementation modules from importing through broad internal barrels", () => { + const broadBarrelImportPattern = + /\bimport\s+(?:type\s+)?(?:[^;]*?\s+from\s+)?["'](?:\.\/|(?:\.\.\/)+)(?:util|graphs|indexer)\.js["']|import\(["'](?:\.\/|(?:\.\.\/)+)(?:util|graphs|indexer)\.js["']\)/; + const facadeFiles = new Set(["src/index.ts", "src/graphs.ts", "src/indexer.ts"]); + const offenders = listFilesRecursive("src", ".ts").filter((relativePath) => { + if (facadeFiles.has(relativePath)) { + return false; + } + return broadBarrelImportPattern.test(readText(relativePath)); + }); + + expect(offenders).toEqual([]); + }); + it("keeps the graph public module as a lightweight facade", () => { const graphFacade = readText("src/graphs.ts"); @@ -311,16 +325,18 @@ describe("package metadata", () => { expect(scripts["test:coverage"]).toBe("node ./scripts/coverage.mjs js"); expect(scripts["test:coverage:native"]).toBe("node ./scripts/coverage.mjs native"); expect(scripts["test:coverage:all"]).toBe("node ./scripts/coverage.mjs all"); - expect(scripts["coverage:setup:native"]).toBe("rustup component add llvm-tools-preview && cargo install cargo-llvm-cov --locked"); + expect(scripts["coverage:setup:native"]).toBe( + "rustup component add llvm-tools-preview && cargo install cargo-llvm-cov --locked", + ); expect(scripts["native:check-artifacts"]).toBe("node ./scripts/check-native-artifacts.mjs"); expect(scripts["native:stage-local"]).toBe("node ./scripts/stage-native-package.mjs"); expect(devDependencies["@vitest/coverage-v8"]).toBeDefined(); - expect(vitestConfig).toContain("provider: \"v8\""); - expect(vitestConfig).toContain("include: [\"src/**/*.{ts,tsx}\"]"); - expect(vitestConfig).toContain("\"src/indexer/import-types.ts\""); - expect(vitestConfig).not.toContain("\"src/impact/types.ts\""); - expect(vitestConfig).toContain("reporter: [\"text\", \"html\", \"lcov\"]"); - expect(vitestConfig).toContain("reportsDirectory: \"./coverage/js\""); + expect(vitestConfig).toContain('provider: "v8"'); + expect(vitestConfig).toContain('include: ["src/**/*.{ts,tsx}"]'); + expect(vitestConfig).toContain('"src/indexer/import-types.ts"'); + expect(vitestConfig).not.toContain('"src/impact/types.ts"'); + expect(vitestConfig).toContain('reporter: ["text", "html", "lcov"]'); + expect(vitestConfig).toContain('reportsDirectory: "./coverage/js"'); expect(coverageScript).toContain("cargo llvm-cov"); expect(coverageScript).toContain("coverage/native"); expect(coverageScript).toContain("./native/html/index.html"); @@ -575,7 +591,7 @@ void onImpactItemStreaming; expect(workflow).toContain('test -e "/lib/${host_triplet}/libgcc_s.so.1"'); expect(workflow).toContain("LIBRARY_PATH=/usr/lib/${host_triplet}:/lib/${host_triplet}:${LIBRARY_PATH:-}"); expect(workflow).toContain("RUSTFLAGS=-C link-arg=-L/usr/lib/${host_triplet}"); - expect(workflow).toContain("bumpVersion(nativePackage.version, \"${{ inputs.release_type }}\")"); + expect(workflow).toContain('bumpVersion(nativePackage.version, "${{ inputs.release_type }}")'); expect(workflow).toContain("needs.plan-native-release.outputs.version"); expect(workflow).toContain('hasTagForPackageVersion("@lzehrung/codegraph-native", version, tagNames)'); expect(installIndex).toBeGreaterThan(-1); diff --git a/tests/review.test.ts b/tests/review.test.ts index 79a5f214..8684c5da 100644 --- a/tests/review.test.ts +++ b/tests/review.test.ts @@ -5,7 +5,9 @@ import path from "node:path"; import fsp from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { buildProjectIndex, buildProjectIndexFromFiles, buildReviewReport } from "../src/index.js"; -import * as indexer from "../src/indexer.js"; +import * as indexerBuild from "../src/indexer/build-index.js"; +import * as indexerNavigation from "../src/indexer/navigation.js"; +import type { IncrementalBuildOptions, SymbolDef } from "../src/indexer/types.js"; import * as impactMap from "../src/impact/map.js"; async function mkTmpDir(prefix: string): Promise { @@ -1264,14 +1266,14 @@ describe("Review report", () => { await buildProjectIndex(root); - type RefResult = Awaited>; + type RefResult = Awaited>; const deferreds: Array<{ promise: Promise; resolve: (value: RefResult) => void; - def: indexer.SymbolDef | null; + def: SymbolDef | null; }> = []; - const createDeferred = (def: indexer.SymbolDef | null) => { + const createDeferred = (def: SymbolDef | null) => { let resolve: (value: RefResult) => void = () => {}; const promise = new Promise((res) => { resolve = res; @@ -1281,7 +1283,7 @@ describe("Review report", () => { return entry; }; - const findSpy = vi.spyOn(indexer, "findReferences").mockImplementation((idx, req) => { + const findSpy = vi.spyOn(indexerNavigation, "findReferences").mockImplementation((idx, req) => { const def = "def" in req ? req.def : null; const entry = createDeferred(def ?? null); return entry.promise; @@ -1335,12 +1337,12 @@ describe("Review report", () => { await buildProjectIndex(root); - type RefResult = Awaited>; + type RefResult = Awaited>; const deferreds: Array<{ resolve: (value: RefResult) => void }> = []; let inFlight = 0; let maxInFlight = 0; - const findSpy = vi.spyOn(indexer, "findReferences").mockImplementation(() => { + const findSpy = vi.spyOn(indexerNavigation, "findReferences").mockImplementation(() => { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); let resolveFn: (value: RefResult) => void = () => {}; @@ -1401,17 +1403,19 @@ describe("Review report", () => { await buildProjectIndex(root); - const originalBuildProjectIndexIncremental = indexer.buildProjectIndexIncremental; - const originalFindReferences = indexer.findReferences; - const capturedIndexOpts: Array = []; + const originalBuildProjectIndexIncremental = indexerBuild.buildProjectIndexIncremental; + const originalFindReferences = indexerNavigation.findReferences; + const capturedIndexOpts: Array = []; const capturedReferenceLimits: number[] = []; - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (projectRoot, opts) => { - capturedIndexOpts.push(opts); - return await originalBuildProjectIndexIncremental(projectRoot, opts); - }); + const buildSpy = vi + .spyOn(indexerBuild, "buildProjectIndexIncremental") + .mockImplementation(async (projectRoot, opts) => { + capturedIndexOpts.push(opts); + return await originalBuildProjectIndexIncremental(projectRoot, opts); + }); - const findSpy = vi.spyOn(indexer, "findReferences").mockImplementation(async (idx, req, opts) => { + const findSpy = vi.spyOn(indexerNavigation, "findReferences").mockImplementation(async (idx, req, opts) => { if (opts?.maxReferences !== undefined) { capturedReferenceLimits.push(opts.maxReferences); } @@ -1466,7 +1470,7 @@ describe("Review report", () => { await buildProjectIndex(root); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental"); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { const minimal = await buildReviewReport(root, { files: [featureFile], @@ -1555,7 +1559,7 @@ describe("Indexing helper", () => { const fullMainModule = fullIndex.byFile.get(normalizedMainPath); expect(fullMainModule).toBeDefined(); - const incrementalIndex = await indexer.buildProjectIndexIncremental(root, { + const incrementalIndex = await indexerBuild.buildProjectIndexIncremental(root, { cache: "disk", files: [mainPath], }); diff --git a/tests/session.test.ts b/tests/session.test.ts index 8439fdb0..caeb3e7d 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach, vi } from "vitest"; import type { ICodeReviewSession } from "../src/index.js"; import { CodeReviewSession, SessionManager, createCodeReviewSession } from "../src/session.js"; -import * as indexer from "../src/indexer.js"; +import * as indexerBuild from "../src/indexer/build-index.js"; import path from "node:path"; import os from "node:os"; import fsp from "node:fs/promises"; @@ -194,7 +194,7 @@ index 1234567..abcdef0 100644 }); const buildSpy = vi - .spyOn(indexer, "buildProjectIndexIncremental") + .spyOn(indexerBuild, "buildProjectIndexIncremental") .mockRejectedValue(new Error("synthetic refresh failure")); try { @@ -259,12 +259,12 @@ index 1234567..abcdef0 100644 }); test("should keep a disposed session expired when init completes later", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -292,12 +292,12 @@ index 1234567..abcdef0 100644 root: sampleRoot, buildOptions: { cache: "memory", useBloomFilters: true }, }); - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -387,7 +387,7 @@ describe("SessionManager", () => { }); test("should share one initialization across concurrent same-id creation", async () => { - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental"); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { const [sessionA, sessionB] = await Promise.all([ @@ -411,12 +411,12 @@ describe("SessionManager", () => { }); test("should not repopulate a session disposed during initialization", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -440,12 +440,12 @@ describe("SessionManager", () => { }); test("should allow immediate recreation after disposing a pending session", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -476,12 +476,12 @@ describe("SessionManager", () => { }); test("should allow immediate recreation after disposeAll cancels a pending session", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -537,7 +537,7 @@ describe("SessionManager", () => { expect(session.getStatus()).toBe("expired"); const buildSpy = vi - .spyOn(indexer, "buildProjectIndexIncremental") + .spyOn(indexerBuild, "buildProjectIndexIncremental") .mockRejectedValue(new Error("synthetic reinit failure")); try { @@ -767,12 +767,12 @@ describe("SessionManager", () => { }); test("should not repopulate sessions when warmup is disposed mid-initialization", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -801,12 +801,12 @@ describe("SessionManager", () => { }); test("should share warmup work with concurrent getOrCreateSession", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); @@ -839,12 +839,12 @@ describe("SessionManager", () => { }); test("should warm multiple independent sessions in parallel", async () => { - const originalBuild = indexer.buildProjectIndexIncremental; + const originalBuild = indexerBuild.buildProjectIndexIncremental; let releaseBuild: (() => void) | null = null; const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); - const buildSpy = vi.spyOn(indexer, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { await buildGate; return await originalBuild(...args); }); From 57ed8c25921e5fb4f2ba56853e7a2d5e65445d01 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 18:16:49 -0400 Subject: [PATCH 12/25] Document public API boundary --- README.md | 2 ++ REVIEW_ANALYSIS_NEXT.md | 4 ++-- docs/library-api.md | 31 +++++++++++++++++++++++++++++++ src/index.ts | 7 +++++++ tests/package-metadata.test.ts | 17 +++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c0aa173..b894d05e 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ const wrapped = await tool_impactJSON(root, { provider: "git", base: "HEAD", hea Good downstream packs preserve structured fields such as symbol handles, ranges, diff snippets, callsites, graph edges, candidate-test confidence, impact reasons, diagnostics, and `schemaVersion`/`format`. Streaming callers that only need incremental chunks can set `streamSummary: "light"` to skip terminal suggestions, export summaries, re-export chains, ranked top impacts, graph metadata, cycles, clusters, and surface-area work. Use [docs/library-api.md](./docs/library-api.md) for the full API reference and [docs/agent-workflows.md](./docs/agent-workflows.md) for session and streaming recipes. +The supported package import surface is the root export, `@lzehrung/codegraph`. The public API boundary and compatibility-export guidance live in [docs/library-api.md](./docs/library-api.md#public-api-boundary). + ## Common workflows - Repo triage: run `codegraph inspect ./src --limit 20`, then follow with `codegraph hotspots ./src --limit 20` or `codegraph unresolved` to focus the next pass. diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 20e2c572..d28c5e52 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -95,7 +95,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Target: implementation modules import direct leaf modules unless they are intentionally using a stable domain facade. - Add or extend package metadata/source-structure tests to prevent imports through the root public API and to limit broad internal barrel usage where practical. -- [ ] Audit the public root API in `src/index.ts`. +- [x] Audit the public root API in `src/index.ts`. - Finding: package exports only `"."`, while `src/index.ts` reexports a very broad surface of build, graph, impact, session, agent, MCP, SQLite, chunking, language, and utility APIs. - Target: classify exports as public-stable, public-legacy, or internal-only. Deprecate or move internal-only exports behind narrower subpath exports in a planned major/minor-compatible way. - Update `docs/library-api.md`, `README.md`, and package metadata tests when the public contract changes. @@ -145,7 +145,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Explain `--root`, positional include roots, config `ignoreGlobs`, `--include-glob`, `--ignore-glob`, gitignore handling, and cache reuse. - Update `docs/cli.md` and `codegraph-skill/codegraph/SKILL.md` if command behavior or examples change. -- [ ] Document public API boundaries before shrinking exports. +- [x] Document public API boundaries before shrinking exports. - Add a short public/internal API section to `docs/library-api.md`. - Note which barrels are stable entry points and which modules are implementation details. diff --git a/docs/library-api.md b/docs/library-api.md index 7fe9b7f6..f09cb8bb 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -34,6 +34,37 @@ const index = await buildProjectIndex(root, { }); ``` +## Public API Boundary + +The npm package exposes one supported entry point: `@lzehrung/codegraph`. +Do not import from generated paths such as `@lzehrung/codegraph/dist/...` or +repo-internal source paths. Those modules are implementation details and can +move during refactors. + +The root entry point is intentionally broad today for compatibility. Treat it +as three groups: + +- Public-stable APIs are the documented integration surface: indexing and + navigation (`buildProjectIndex`, `buildProjectIndexIncremental`, + `goToDefinition`, `findReferences`, symbol handles, graph builders and + renderers), impact and review reports, sessions, agent search/explain/artifact + helpers, MCP handlers, SQLite helpers, SQL artifact APIs, chunking, config, + language metadata, and native runtime capability checks. +- Public-legacy APIs remain exported for existing callers but are lower-level + building blocks. This includes parser-facing helpers such as `parseFile`, + `collectImportsForFile`, `collectLocalsAndExportsFromSource`, + `buildScopeIndexFromSource`, selected shared utilities, lazy symbol wrappers, + symbol hashing helpers, and partial-result helpers. New integrations should + prefer the documented higher-level APIs unless they specifically need these + shapes. +- Internal-only modules are anything outside the root package export. They are + not covered by semver, even when their generated declaration files exist in + `dist/`. + +Future API narrowing should happen by first documenting replacements, then +adding explicit subpath exports or deprecation notes before removing root +compatibility exports. + ## Agent search `searchCodegraph()` builds a project snapshot and returns deterministic, agent-ready anchors across files, symbols, chunks, SQL objects, and optional graph neighborhoods. Handles are project-relative and explainable; large result packets include `resultCount`, `totalCandidates`, `limits`, and `omittedCounts`. diff --git a/src/index.ts b/src/index.ts index 0baa34fa..d041bd3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,10 @@ +/** + * Public package entrypoint. + * + * The npm package currently exposes only `@lzehrung/codegraph`; implementation + * modules below `dist/` are not a stable import surface for consumers. + */ + /** Language registry helpers and support metadata. */ export * from "./languages.js"; diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts index b8319513..5fb6eceb 100644 --- a/tests/package-metadata.test.ts +++ b/tests/package-metadata.test.ts @@ -469,6 +469,23 @@ describe("package metadata", () => { } }); + it("keeps the public API boundary documented around the root package export", () => { + const rootPackage = readJson("package.json"); + const rootExports = rootPackage.exports; + expect(rootExports).toBeDefined(); + expect(Object.keys(rootExports as Record).sort()).toEqual(["."]); + + const libraryApi = readText("docs/library-api.md"); + expect(libraryApi).toContain("## Public API Boundary"); + expect(libraryApi).toContain("Public-stable APIs"); + expect(libraryApi).toContain("Public-legacy APIs"); + expect(libraryApi).toContain("Internal-only modules"); + expect(libraryApi).toContain("@lzehrung/codegraph/dist/..."); + + const readme = readText("README.md"); + expect(readme).toContain("./docs/library-api.md#public-api-boundary"); + }); + it("scopes streaming summary mode to the streaming API type", () => { const impactTypes = readText("src/impact/types.ts"); const streamingSource = readText("src/impact/streaming.ts"); From 9146fcd832ef1570757a7f99c2f1cd03d56aa0aa Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 18:23:17 -0400 Subject: [PATCH 13/25] Unify agent path helpers --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/agent/artifact.ts | 24 +++++++--------- src/agent/explain.ts | 64 +++++++++++++++-------------------------- src/agent/normalize.ts | 27 +++++++++++++++++ src/agent/search.ts | 43 +++++++++------------------ 5 files changed, 75 insertions(+), 85 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index d28c5e52..dd54f4ab 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -102,7 +102,7 @@ No dependency cycles were reported. The highest remaining concentration is in: ### Duplication And Shared Helpers -- [ ] Unify agent file/path/follow-up helpers. +- [x] Unify agent file/path/follow-up helpers. - Finding: `relativeFile` wrappers remain in `src/agent/search.ts`, `src/agent/explain.ts`, and `src/agent/artifact.ts`; search/explain also duplicate symbol/file target resolution patterns. - Target: expand `src/agent/normalize.ts` or add `src/agent/targets.ts` for file resolution, SQL-object detection, handle resolution, and common follow-up construction. - Verify with `tests/agent-search.test.ts`, `tests/agent-explain.test.ts`, and `tests/artifact-build.test.ts`. diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 2288bda1..6e9da258 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -405,14 +405,14 @@ function buildGraphJson(snapshot: AgentProjectSnapshot): { const symbolIds = buildPortableSymbolIdMap(snapshot); const portableSymbolId = (id: string): string => symbolIds.get(id) ?? id; const graph: PortableGraphBody = { - files: [...snapshot.fileGraph.nodes].map((file) => relativeFile(snapshot.root, file)).sort(), + files: [...snapshot.fileGraph.nodes].map((file) => normalizeAgentFilePath(snapshot.root, file)).sort(), fileEdges: [...snapshot.fileGraph.edges] .map((edge) => ({ ...edge, - from: relativeFile(snapshot.root, edge.from), + from: normalizeAgentFilePath(snapshot.root, edge.from), to: edge.to.type === "file" - ? { type: "file" as const, path: relativeFile(snapshot.root, edge.to.path) } + ? { type: "file" as const, path: normalizeAgentFilePath(snapshot.root, edge.to.path) } : { type: "external" as const, name: edge.to.name }, })) .sort((left, right) => { @@ -426,7 +426,7 @@ function buildGraphJson(snapshot: AgentProjectSnapshot): { .map((node) => ({ ...node, id: portableSymbolId(node.id), - file: relativeFile(snapshot.root, node.file), + file: normalizeAgentFilePath(snapshot.root, node.file), })) .sort((left, right) => { const fileDelta = left.file.localeCompare(right.file); @@ -463,7 +463,7 @@ function buildPortableSymbolIdMap(snapshot: AgentProjectSnapshot): Map(); for (const moduleIndex of snapshot.index.byFile.values()) { for (const local of moduleIndex.locals) { - const relFile = relativeFile(snapshot.root, local.file); + const relFile = normalizeAgentFilePath(snapshot.root, local.file); const id = defNodeId(local); const isSqlObject = local.file.toLowerCase().endsWith(".sql"); byId.set( @@ -482,7 +482,7 @@ function buildPortableSymbolIdMap(snapshot: AgentProjectSnapshot): Map - `- ${relativeFile(snapshot.root, hotspot.file)} (fan-in ${hotspot.fanIn}, fan-out ${hotspot.fanOut}, score ${hotspot.score})`, + `- ${normalizeAgentFilePath(snapshot.root, hotspot.file)} (fan-in ${hotspot.fanIn}, fan-out ${hotspot.fanOut}, score ${hotspot.score})`, ); } @@ -619,7 +619,7 @@ function buildQuestions(snapshot: AgentProjectSnapshot): ArtifactQuestion[] { const questions: ArtifactQuestion[] = []; const hotspots = getHotspots(snapshot.fileGraph, { limit: 3 }); for (const hotspot of hotspots) { - const file = relativeFile(snapshot.root, hotspot.file); + const file = normalizeAgentFilePath(snapshot.root, hotspot.file); questions.push({ id: `rdeps:${file}`, question: `Which files depend on ${file}?`, @@ -661,7 +661,7 @@ function collectExportedSymbols( for (const moduleIndex of snapshot.index.byFile.values()) { for (const exportEntry of moduleIndex.exports) { if (exportEntry.type !== "local") continue; - const file = relativeFile(snapshot.root, exportEntry.target.file); + const file = normalizeAgentFilePath(snapshot.root, exportEntry.target.file); exported.push({ name: exportEntry.exportedAs, localName: exportEntry.target.localName, @@ -690,7 +690,7 @@ function collectSqlObjects( return [...snapshot.symbolGraph.nodes.values()] .filter((node) => node.kind === "table" || node.kind === "view" || node.kind === "index" || node.kind === "routine") .map((node) => { - const file = relativeFile(snapshot.root, node.file); + const file = normalizeAgentFilePath(snapshot.root, node.file); const def = snapshot.index.byFile.get(node.file)?.locals.find((local) => defNodeId(local) === node.id); return { name: node.name, @@ -709,7 +709,3 @@ function collectSqlObjects( async function writeJson(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } - -function relativeFile(root: string, file: string): string { - return normalizeAgentFilePath(root, file); -} diff --git a/src/agent/explain.ts b/src/agent/explain.ts index 8a81c13f..7429ba55 100644 --- a/src/agent/explain.ts +++ b/src/agent/explain.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import path from "node:path"; import { findReferences } from "../indexer/navigation.js"; import type { Reference, SymbolDef } from "../indexer/types.js"; import { getDependencies, getReverseDependencies } from "../graphs/queries.js"; @@ -26,7 +25,10 @@ import { import { collectDefinitionFollowUps, collectFileFollowUps as collectCommonFileFollowUps, + isAgentSqlFile, + isAgentSqlObjectNode, normalizeAgentFilePath, + resolveAgentSnapshotFile, } from "./normalize.js"; import { createAgentSession, type AgentProjectSnapshot, type AgentSession } from "./session.js"; import { quoteShellArg } from "./shell.js"; @@ -208,7 +210,7 @@ function resolveTarget(snapshot: AgentProjectSnapshot, lookup: SymbolLookup, tar const chunkHandle = parseAgentChunkHandle(target); const graphHandle = parseAgentGraphHandle(target); const fileTarget = fileHandle?.file ?? chunkHandle?.file ?? graphHandle?.file ?? target; - const file = resolveFileCandidate(snapshot, fileTarget); + const file = resolveAgentSnapshotFile(snapshot, fileTarget); if (file) return { kind: "file", file }; if (target.startsWith("symbol:")) { @@ -237,10 +239,10 @@ function resolveSymbolHandle( ): Extract | null { const parsed = parseAgentSymbolHandle(handle); if (parsed) { - const file = resolveFileCandidate(snapshot, parsed.file); + const file = resolveAgentSnapshotFile(snapshot, parsed.file); if (!file) return null; for (const def of lookup.defById.values()) { - if (isSqlFile(def.file)) continue; + if (isAgentSqlFile(def.file)) continue; if (normalizePath(def.file) !== file) continue; if (def.localName !== parsed.name) continue; if (def.range.start.line !== parsed.line || def.range.start.column !== parsed.column) continue; @@ -248,7 +250,7 @@ function resolveSymbolHandle( } for (const node of snapshot.symbolGraph.nodes.values()) { const def = lookup.defById.get(node.id); - if (!def || isSqlObjectNode(node)) continue; + if (!def || isAgentSqlObjectNode(node)) continue; if (normalizePath(node.file) !== file) continue; if (node.name !== parsed.name) continue; if (def.range.start.line !== parsed.line || def.range.start.column !== parsed.column) continue; @@ -262,14 +264,6 @@ function resolveSymbolHandle( return def ? symbolTarget(def, snapshot.symbolGraph.nodes.get(id)) : null; } -function resolveFileCandidate(snapshot: AgentProjectSnapshot, candidate: string): string | null { - const normalizedFiles = new Map(snapshot.files.map((file) => [normalizePath(file), normalizePath(file)])); - const absoluteCandidate = path.isAbsolute(candidate) - ? normalizePath(candidate) - : normalizePath(path.resolve(snapshot.root, candidate)); - return normalizedFiles.get(absoluteCandidate) ?? null; -} - function resolveSqlHandle( snapshot: AgentProjectSnapshot, lookup: SymbolLookup, @@ -280,10 +274,10 @@ function resolveSqlHandle( for (const node of snapshot.symbolGraph.nodes.values()) { const def = lookup.defById.get(node.id); - if (!def || !isSqlObjectNode(node)) continue; + if (!def || !isAgentSqlObjectNode(node)) continue; if ( node.name === parsed.name && - relativeFile(snapshot.root, node.file) === parsed.file && + normalizeAgentFilePath(snapshot.root, node.file) === parsed.file && def.range.start.line === parsed.line ) { return { kind: "sql_object", def, node }; @@ -298,7 +292,7 @@ function findSymbolByName( name: string, ): Extract | null { const matches = [...snapshot.symbolGraph.nodes.values()] - .filter((node) => !isSqlObjectNode(node) && node.name === name) + .filter((node) => !isAgentSqlObjectNode(node) && node.name === name) .sort(compareSymbolNodes); const node = matches[0]; if (node) { @@ -307,7 +301,7 @@ function findSymbolByName( } const defMatches = [...lookup.defById.values()] - .filter((def) => !isSqlFile(def.file) && def.localName === name) + .filter((def) => !isAgentSqlFile(def.file) && def.localName === name) .sort(compareSymbolDefs); const def = defMatches[0]; return def ? symbolTarget(def, snapshot.symbolGraph.nodes.get(defNodeId(def))) : null; @@ -319,7 +313,7 @@ function findSqlObjectByName( name: string, ): Extract | null { const exactMatches = [...snapshot.symbolGraph.nodes.values()] - .filter((node) => isSqlObjectNode(node) && node.name === name) + .filter((node) => isAgentSqlObjectNode(node) && node.name === name) .sort(compareSymbolNodes); const exactNode = exactMatches[0]; if (exactNode) { @@ -330,7 +324,7 @@ function findSqlObjectByName( if (name.includes(".")) return null; const matches = [...snapshot.symbolGraph.nodes.values()] - .filter((node) => isSqlObjectNode(node) && basename(node.name) === name) + .filter((node) => isAgentSqlObjectNode(node) && basename(node.name) === name) .sort(compareSymbolNodes); if (matches.length !== 1) return null; const node = matches[0]; @@ -397,7 +391,7 @@ async function buildExplanation( } const file = resolved.kind === "file" ? resolved.file : normalizePath(resolved.def.file); - const relFile = relativeFile(snapshot.root, file); + const relFile = normalizeAgentFilePath(snapshot.root, file); const allSymbols = collectFileSymbols(snapshot, lookup, file); const boundedSymbols = boundAgentList(allSymbols, maxSymbols); const dependencies = collectDependencies(snapshot, file, maxDependencies, "forward"); @@ -558,7 +552,7 @@ function collectDependencies( : getReverseDependencies(snapshot.fileGraph, startFile, { depth: 1 }); const sortedDependencies = dependencies .map((dependency) => ({ - file: relativeFile(snapshot.root, dependency.file), + file: normalizeAgentFilePath(snapshot.root, dependency.file), depth: dependency.depth, })) .sort(compareDependencies); @@ -579,7 +573,7 @@ function collectTargetHotspots( return getHotspots(snapshot.fileGraph, { limit: snapshot.files.length }) .filter((hotspot) => normalizePath(hotspot.file) === normalizedFile) .map((hotspot) => ({ - file: relativeFile(snapshot.root, hotspot.file), + file: normalizeAgentFilePath(snapshot.root, hotspot.file), fanIn: hotspot.fanIn, fanOut: hotspot.fanOut, score: hotspot.score, @@ -598,7 +592,7 @@ async function collectReferenceContext( const references = result.references .map((reference) => ({ - file: relativeFile(snapshot.root, reference.file), + file: normalizeAgentFilePath(snapshot.root, reference.file), range: reference.range, })) .sort((left, right) => { @@ -626,7 +620,7 @@ async function collectReferenceContext( function snippetFromReference(snapshot: AgentProjectSnapshot, reference: Reference): AgentExplanationSnippet { return { - file: relativeFile(snapshot.root, reference.file), + file: normalizeAgentFilePath(snapshot.root, reference.file), line: reference.range.start.line, text: reference.context ?? "", }; @@ -639,7 +633,7 @@ async function collectRelatedSqlObjects( file: string, limit: number, ): Promise> { - if (resolved.kind !== "sql_object" && !isSqlFile(file)) return emptyAgentBoundedList(); + if (resolved.kind !== "sql_object" && !isAgentSqlFile(file)) return emptyAgentBoundedList(); const sqlObjects = collectSqlObjectNodes(snapshot, lookup); const targetName = resolved.kind === "sql_object" ? (resolved.node?.name ?? resolved.def.localName) : undefined; @@ -648,7 +642,7 @@ async function collectRelatedSqlObjects( const entry: AgentExplanationSqlObject = { name: object.name, kind: object.kind, - file: relativeFile(snapshot.root, object.file), + file: normalizeAgentFilePath(snapshot.root, object.file), relation, ...(object.def ? { range: object.def.range } : {}), }; @@ -689,7 +683,7 @@ type SqlObjectNodeInfo = { function collectSqlObjectNodes(snapshot: AgentProjectSnapshot, lookup: SymbolLookup): SqlObjectNodeInfo[] { return [...snapshot.symbolGraph.nodes.values()] - .filter((node) => isSqlObjectNode(node)) + .filter((node) => isAgentSqlObjectNode(node)) .map((node) => { const base = { id: node.id, @@ -774,7 +768,7 @@ async function addRelatedSqlObjectsFromFacts( } async function collectSqlFacts(snapshot: AgentProjectSnapshot): Promise> { - const sqlFiles = snapshot.files.filter(isSqlFile).sort((left, right) => left.localeCompare(right)); + const sqlFiles = snapshot.files.filter(isAgentSqlFile).sort((left, right) => left.localeCompare(right)); const entries = await mapLimit(sqlFiles, SQL_FACT_READ_CONCURRENCY, async (file) => { const facts = extractSqlFactsFromSource(file, await fs.readFile(file, "utf8")); return [file, facts] as const; @@ -855,7 +849,7 @@ function collectFollowUps( ); } - if (isSqlFile(path.resolve(snapshot.root, relFile))) { + if (isAgentSqlFile(relFile)) { followUps.add(`codegraph search ${quoteShellArg(relFile)} --mode sql --json`); } @@ -920,15 +914,3 @@ async function collectChangedContext(request: AgentExplainTarget): Promise [normalizePath(file), normalizePath(file)])); + const absoluteCandidate = path.isAbsolute(candidate) + ? normalizePath(candidate) + : normalizePath(path.resolve(snapshot.root, candidate)); + return normalizedFiles.get(absoluteCandidate) ?? null; +} + +export function isAgentSqlFile(file: string): boolean { + return file.toLowerCase().endsWith(".sql"); +} + +export function isAgentSqlObjectKind(kind: string): kind is AgentSqlObjectKind { + return kind === "table" || kind === "view" || kind === "index" || kind === "routine"; +} + +export function isAgentSqlObjectNode(node: { kind: string }): boolean { + return isAgentSqlObjectKind(node.kind); +} + export function collectFileFollowUps(file: string): string[] { return [ `codegraph deps ${quoteShellArg(file)} --json`, diff --git a/src/agent/search.ts b/src/agent/search.ts index 57b6c3dd..e023f17b 100644 --- a/src/agent/search.ts +++ b/src/agent/search.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import path from "node:path"; import { LANG_CONFIGS } from "../bootstrap/treeSitterLanguages.js"; import { supportForFile } from "../languages.js"; import { chunkFile } from "../chunking/chunkFile.js"; @@ -24,7 +23,9 @@ import { import { collectDefinitionFollowUps, collectFileFollowUps as collectCommonFileFollowUps, + isAgentSqlObjectNode, normalizeAgentFilePath, + resolveAgentSnapshotFile, } from "./normalize.js"; import { createAgentSession, type AgentProjectSnapshot, type AgentSession } from "./session.js"; import { quoteShellArg } from "./shell.js"; @@ -314,19 +315,19 @@ function addSymbolResults( mode: AgentSearchMode, ): void { for (const node of snapshot.symbolGraph.nodes.values()) { - const sqlObject = isSqlObjectNode(node); + const sqlObject = isAgentSqlObjectNode(node); if (mode === "sql" && !sqlObject) continue; if (mode === "symbol" && sqlObject) continue; const nameMatch = matchTokenScore(node.name, tokens); - const fileMatch = matchTokenScore(relativeFile(snapshot.root, node.file), tokens); + const fileMatch = matchTokenScore(normalizeAgentFilePath(snapshot.root, node.file), tokens); const docMatch = node.docstring ? matchTokenScore(node.docstring, tokens) : { score: 0, matched: [] }; const score = nameMatch.score * 4 + fileMatch.score + docMatch.score; if (score <= 0) continue; const def = lookup.defById.get(node.id); if (!def) continue; - const relFile = relativeFile(snapshot.root, node.file); + const relFile = normalizeAgentFilePath(snapshot.root, node.file); const handle = sqlObject ? formatAgentSqlHandle({ name: node.name, file: relFile, line: def.range.start.line }) : formatAgentSymbolHandle({ @@ -363,10 +364,6 @@ function addSymbolResults( } } -function isSqlObjectNode(node: SymbolNode): boolean { - return node.kind === "table" || node.kind === "view" || node.kind === "index" || node.kind === "routine"; -} - function addPathResults( snapshot: AgentProjectSnapshot, resultMap: Map, @@ -374,7 +371,7 @@ function addPathResults( tokens: string[], ): void { for (const file of snapshot.files) { - const relFile = relativeFile(snapshot.root, file); + const relFile = normalizeAgentFilePath(snapshot.root, file); const pathMatch = matchTokenScore(relFile, tokens); if (pathMatch.score <= 0) continue; @@ -407,7 +404,7 @@ async function addTextResults( const match = matchTokenScore([chunk.name, chunk.text].filter(Boolean).join(" "), tokens); if (match.score <= 0) continue; - const relFile = relativeFile(snapshot.root, file); + const relFile = normalizeAgentFilePath(snapshot.root, file); const handle = formatAgentChunkHandle({ file: relFile, line: chunk.startLine }); const result = upsertResult(resultMap, { handle, @@ -451,7 +448,7 @@ function applyGraphNeighborhood( const reachable = collectReachableFiles(fileNeighborIndex, anchorFiles, depth); for (const entry of reachable.values()) { - const relFile = relativeFile(snapshot.root, entry.file); + const relFile = normalizeAgentFilePath(snapshot.root, entry.file); const existingResults = [...resultMap.values()].filter((result) => result.file === relFile); const fileMatch = matchTokenScore(relFile, tokens); if (fileMatch.score > 0) { @@ -488,18 +485,18 @@ function graphBoost(distance: number): number { function resolveAnchorFiles(snapshot: AgentProjectSnapshot, from: string): Set { const anchor = new Set(); - const directFile = resolveFileCandidate(snapshot, from); + const directFile = resolveAgentSnapshotFile(snapshot, from); if (directFile) anchor.add(directFile); const fileLikeHandle = parseAgentFileHandle(from) ?? parseAgentChunkHandle(from) ?? parseAgentGraphHandle(from); if (fileLikeHandle) { - const handleFile = resolveFileCandidate(snapshot, fileLikeHandle.file); + const handleFile = resolveAgentSnapshotFile(snapshot, fileLikeHandle.file); if (handleFile) anchor.add(handleFile); } if (from.startsWith("symbol:")) { const symbolHandle = parseAgentSymbolHandle(from); - const symbolFile = symbolHandle ? resolveFileCandidate(snapshot, symbolHandle.file) : null; + const symbolFile = symbolHandle ? resolveAgentSnapshotFile(snapshot, symbolHandle.file) : null; if (symbolFile) { anchor.add(symbolFile); } else { @@ -511,7 +508,7 @@ function resolveAnchorFiles(snapshot: AgentProjectSnapshot, from: string): Set [normalizePath(file), normalizePath(file)])); - const absoluteCandidate = path.isAbsolute(candidate) - ? normalizePath(candidate) - : normalizePath(path.resolve(snapshot.root, candidate)); - return normalizedFiles.get(absoluteCandidate) ?? null; -} - function collectReachableFiles( fileNeighborIndex: Map, anchorFiles: Set, @@ -713,7 +702,7 @@ function addSymbolNeighbors( neighbors: readonly SymbolNeighbor[], ): void { for (const neighbor of neighbors) { - const relFile = relativeFile(snapshot.root, neighbor.target.file); + const relFile = normalizeAgentFilePath(snapshot.root, neighbor.target.file); const key = `${neighbor.key}:${neighbor.target.id}`; result.neighbors.set(key, { relation: neighbor.relation, @@ -729,7 +718,7 @@ function addFileNeighbors( neighbors: readonly FileNeighbor[], ): void { for (const neighbor of neighbors) { - const relFile = relativeFile(snapshot.root, neighbor.file); + const relFile = normalizeAgentFilePath(snapshot.root, neighbor.file); result.neighbors.set(`${neighbor.relation}:${relFile}`, { relation: neighbor.relation, target: relFile, @@ -799,7 +788,3 @@ function finalizeResult(result: MutableSearchResult): AgentSearchResult { }, }; } - -function relativeFile(root: string, file: string): string { - return normalizeAgentFilePath(root, file); -} From 2d00e0477b0f558a976d6a90e98bb632109ac13f Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 18:32:29 -0400 Subject: [PATCH 14/25] Centralize presentation bounds --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/agent/bounds.ts | 22 ++++-------- src/agent/explain.ts | 74 +++++++++++++++++++++++++------------- src/agent/search.ts | 33 +++++++++-------- src/cli/review.ts | 20 +++++++---- src/presentation/bounds.ts | 50 ++++++++++++++++++++++++++ src/review/candidates.ts | 6 ++-- 7 files changed, 141 insertions(+), 66 deletions(-) create mode 100644 src/presentation/bounds.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index dd54f4ab..e87a4757 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -107,7 +107,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Target: expand `src/agent/normalize.ts` or add `src/agent/targets.ts` for file resolution, SQL-object detection, handle resolution, and common follow-up construction. - Verify with `tests/agent-search.test.ts`, `tests/agent-explain.test.ts`, and `tests/artifact-build.test.ts`. -- [ ] Centralize presentation bounds for CLI/review/agent output. +- [x] Centralize presentation bounds for CLI/review/agent output. - Finding: bounded-output logic is partly shared for agent APIs, but CLI/review still have hard-coded slices such as changed files, review tasks, impact pretty refs, and candidate tests. - Target: named constants and a small presentation-bound helper for CLI summaries, review summaries, agent changed context, and impact pretty output. - Add tests that assert omission/truncation behavior and avoid silently changing output volume. diff --git a/src/agent/bounds.ts b/src/agent/bounds.ts index 2363eecc..345162a6 100644 --- a/src/agent/bounds.ts +++ b/src/agent/bounds.ts @@ -1,7 +1,6 @@ -export type BoundedAgentList = { - items: T[]; - omitted: number; -}; +import { boundList, countOmitted, emptyBoundedList, type BoundedList } from "../presentation/bounds.js"; + +export type BoundedAgentList = BoundedList; export type AgentLimitOptions = { fallback?: number; @@ -26,20 +25,11 @@ export function defaultAgentLimit(limit: number | undefined, fallback: number, m } export function boundAgentList(items: readonly T[], limit: number): BoundedAgentList { - const boundedItems = items.slice(0, limit); - return { - items: boundedItems, - omitted: countOmitted(items.length, boundedItems.length), - }; + return boundList(items, limit); } export function emptyAgentBoundedList(): BoundedAgentList { - return { - items: [], - omitted: 0, - }; + return emptyBoundedList(); } -export function countOmitted(total: number, visible: number): number { - return Math.max(0, total - visible); -} +export { countOmitted }; diff --git a/src/agent/explain.ts b/src/agent/explain.ts index 7429ba55..5af702ce 100644 --- a/src/agent/explain.ts +++ b/src/agent/explain.ts @@ -5,6 +5,21 @@ import { getDependencies, getReverseDependencies } from "../graphs/queries.js"; import { getHotspots } from "../graphs/hotspots.js"; import { defNodeId } from "../graphs/symbol-graph.js"; import { type SymbolNode } from "../graphs/symbol-graph.js"; +import { + AGENT_EXPLAIN_CANDIDATE_TEST_LIMIT, + AGENT_EXPLAIN_CHANGED_FILE_LIMIT, + AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + AGENT_EXPLAIN_DEFAULT_SNIPPET_LIMIT, + AGENT_EXPLAIN_DEFAULT_SYMBOL_LIMIT, + AGENT_EXPLAIN_FILE_SYMBOL_REF_LIMIT, + AGENT_EXPLAIN_FORMAT_FOLLOWUP_LIMIT, + AGENT_EXPLAIN_FORMAT_SYMBOL_LIMIT, + AGENT_EXPLAIN_MAX_DEPENDENCY_LIMIT, + AGENT_EXPLAIN_MAX_SNIPPET_LIMIT, + AGENT_EXPLAIN_MAX_SYMBOL_LIMIT, + AGENT_EXPLAIN_REVIEW_CONTEXT_CANDIDATE_LIMIT, + AGENT_EXPLAIN_REVIEW_TASK_LIMIT, +} from "../presentation/bounds.js"; import { buildReviewReport } from "../review.js"; import { extractSqlFactsFromSource, sqlObjectBaseName } from "../sql/extractFacts.js"; import type { SqlStatementFact } from "../sql/types.js"; @@ -141,12 +156,6 @@ type ResolvedExplainTarget = | { kind: "sql_object"; def: SymbolDef; node?: SymbolNode } | { kind: "not_found"; label: string }; -const DEFAULT_MAX_DEPENDENCIES = 20; -const DEFAULT_MAX_SNIPPETS = 8; -const DEFAULT_MAX_SYMBOLS = 50; -const MAX_DEPENDENCIES = 100; -const MAX_SNIPPETS = 50; -const MAX_SYMBOLS = 200; const SQL_FACT_READ_CONCURRENCY = 32; export async function explainCodegraphTarget(request: AgentExplainTarget): Promise { @@ -173,7 +182,7 @@ export function formatAgentExplanation(explanation: AgentExplanation): string { lines.push( `symbols: ${explanation.symbols .map((symbol) => symbol.name) - .slice(0, 8) + .slice(0, AGENT_EXPLAIN_FORMAT_SYMBOL_LIMIT) .join(", ")}`, ); } @@ -184,7 +193,10 @@ export function formatAgentExplanation(explanation: AgentExplanation): string { lines.push(`rdeps: ${explanation.reverseDependencies.map((entry) => entry.file).join(", ")}`); } if (explanation.followUps.length) { - lines.push("follow-ups:", ...explanation.followUps.slice(0, 8).map((command) => ` ${command}`)); + lines.push( + "follow-ups:", + ...explanation.followUps.slice(0, AGENT_EXPLAIN_FORMAT_FOLLOWUP_LIMIT).map((command) => ` ${command}`), + ); } return lines.join("\n"); } @@ -369,19 +381,31 @@ async function buildExplanation( resolved: ResolvedExplainTarget, request: AgentExplainTarget, ): Promise { - const maxDependencies = defaultAgentLimit(request.maxDependencies, DEFAULT_MAX_DEPENDENCIES, MAX_DEPENDENCIES); + const maxDependencies = defaultAgentLimit( + request.maxDependencies, + AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + AGENT_EXPLAIN_MAX_DEPENDENCY_LIMIT, + ); const maxReferences = defaultAgentLimit( request.maxReferences ?? request.maxDependencies, - DEFAULT_MAX_DEPENDENCIES, - MAX_DEPENDENCIES, + AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + AGENT_EXPLAIN_MAX_DEPENDENCY_LIMIT, ); const maxRelatedSqlObjects = defaultAgentLimit( request.maxRelatedSqlObjects ?? request.maxDependencies, - DEFAULT_MAX_DEPENDENCIES, - MAX_DEPENDENCIES, + AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + AGENT_EXPLAIN_MAX_DEPENDENCY_LIMIT, + ); + const maxSnippets = defaultAgentLimit( + request.maxSnippets, + AGENT_EXPLAIN_DEFAULT_SNIPPET_LIMIT, + AGENT_EXPLAIN_MAX_SNIPPET_LIMIT, + ); + const maxSymbols = defaultAgentLimit( + request.maxSymbols, + AGENT_EXPLAIN_DEFAULT_SYMBOL_LIMIT, + AGENT_EXPLAIN_MAX_SYMBOL_LIMIT, ); - const maxSnippets = defaultAgentLimit(request.maxSnippets, DEFAULT_MAX_SNIPPETS, MAX_SNIPPETS); - const maxSymbols = defaultAgentLimit(request.maxSymbols, DEFAULT_MAX_SYMBOLS, MAX_SYMBOLS); if (resolved.kind === "not_found") { return emptyExplanation(snapshot, { @@ -462,11 +486,11 @@ function emptyExplanation(snapshot: AgentProjectSnapshot, target: AgentExplanati hotspots: [], followUps: [`codegraph search ${quoteShellArg(target.label)} --json`], limits: { - symbols: DEFAULT_MAX_SYMBOLS, - dependencies: DEFAULT_MAX_DEPENDENCIES, - references: DEFAULT_MAX_DEPENDENCIES, - relatedSqlObjects: DEFAULT_MAX_DEPENDENCIES, - snippets: DEFAULT_MAX_SNIPPETS, + symbols: AGENT_EXPLAIN_DEFAULT_SYMBOL_LIMIT, + dependencies: AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + references: AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + relatedSqlObjects: AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT, + snippets: AGENT_EXPLAIN_DEFAULT_SNIPPET_LIMIT, }, omittedCounts: { symbols: 0, @@ -831,7 +855,7 @@ function collectFollowUps( const followUps = new Set(collectCommonFileFollowUps(relFile)); if (resolved.kind === "file") { - for (const symbol of symbols.slice(0, 5)) { + for (const symbol of symbols.slice(0, AGENT_EXPLAIN_FILE_SYMBOL_REF_LIMIT)) { followUps.add( `codegraph refs --file ${quoteShellArg(relFile)} --line ${symbol.range.start.line} --col ${symbol.range.start.column} --pretty`, ); @@ -894,20 +918,20 @@ async function collectChangedContext(request: AgentExplainTarget): Promise entry.file).slice(0, 20), - reviewTasks: report.reviewTasks.slice(0, 5).map((task) => ({ + changedFiles: report.changedFiles.map((entry) => entry.file).slice(0, AGENT_EXPLAIN_CHANGED_FILE_LIMIT), + reviewTasks: report.reviewTasks.slice(0, AGENT_EXPLAIN_REVIEW_TASK_LIMIT).map((task) => ({ id: task.id, reason: task.reason, summary: task.description, priority: task.priority, })), - candidateTests: report.candidateTests.slice(0, 10).map((candidate) => ({ + candidateTests: report.candidateTests.slice(0, AGENT_EXPLAIN_CANDIDATE_TEST_LIMIT).map((candidate) => ({ file: candidate.file, confidence: candidate.confidence, reason: candidate.reason, diff --git a/src/agent/search.ts b/src/agent/search.ts index e023f17b..ab8427bf 100644 --- a/src/agent/search.ts +++ b/src/agent/search.ts @@ -6,6 +6,14 @@ import type { SymbolDef } from "../indexer/types.js"; import type { Range } from "../types.js"; import { defNodeId } from "../graphs/symbol-graph.js"; import { type SymbolNode } from "../graphs/symbol-graph.js"; +import { + AGENT_SEARCH_EVIDENCE_PER_RESULT_LIMIT, + AGENT_SEARCH_FOLLOWUPS_PER_RESULT_LIMIT, + AGENT_SEARCH_FORMAT_REASON_LIMIT, + AGENT_SEARCH_NEIGHBORS_PER_RESULT_LIMIT, + AGENT_SEARCH_RANK_REASONS_PER_RESULT_LIMIT, + AGENT_SEARCH_RESULT_LIMIT, +} from "../presentation/bounds.js"; import { normalizePath } from "../util/paths.js"; import { boundAgentList, defaultAgentLimit } from "./bounds.js"; import { @@ -132,13 +140,8 @@ type ReachableFile = { }; const DEFAULT_LIMIT = 20; -const MAX_RESULTS = 100; const MAX_TEXT_BYTES = 300_000; const MAX_GRAPH_DEPTH = 5; -const MAX_RANK_REASONS_PER_RESULT = 6; -const MAX_EVIDENCE_PER_RESULT = 5; -const MAX_NEIGHBORS_PER_RESULT = 12; -const MAX_FOLLOWUPS_PER_RESULT = 8; const CHUNK_LANGUAGE_ALIASES: Record = { js: "javascript", ts: "typescript", @@ -166,7 +169,7 @@ export function formatAgentSearchResponse(response: AgentSearchResponse): string const location = result.range ? `${result.file}:${result.range.start.line}:${result.range.start.column}` : result.file; - const reasons = result.rankReasons.slice(0, 3).join("; "); + const reasons = result.rankReasons.slice(0, AGENT_SEARCH_FORMAT_REASON_LIMIT).join("; "); return `${index + 1}. ${result.label} [${result.kind}] ${location} score=${result.score}\n ${reasons}`; }) .join("\n"); @@ -179,7 +182,7 @@ async function searchSnapshot( const mode = request.mode ?? "hybrid"; const tokens = tokenizeQuery(request.query); const resultMap = new Map(); - const limit = defaultAgentLimit(request.limit, DEFAULT_LIMIT, MAX_RESULTS); + const limit = defaultAgentLimit(request.limit, DEFAULT_LIMIT, AGENT_SEARCH_RESULT_LIMIT); let fileNeighborIndex: Map | undefined; const getFileNeighborIndex = (): Map => { fileNeighborIndex ??= buildFileNeighborIndex(snapshot); @@ -221,10 +224,10 @@ async function searchSnapshot( root: snapshot.root, limits: { results: limit, - rankReasonsPerResult: MAX_RANK_REASONS_PER_RESULT, - evidencePerResult: MAX_EVIDENCE_PER_RESULT, - neighborsPerResult: MAX_NEIGHBORS_PER_RESULT, - followUpsPerResult: MAX_FOLLOWUPS_PER_RESULT, + rankReasonsPerResult: AGENT_SEARCH_RANK_REASONS_PER_RESULT_LIMIT, + evidencePerResult: AGENT_SEARCH_EVIDENCE_PER_RESULT_LIMIT, + neighborsPerResult: AGENT_SEARCH_NEIGHBORS_PER_RESULT_LIMIT, + followUpsPerResult: AGENT_SEARCH_FOLLOWUPS_PER_RESULT_LIMIT, }, resultCount: results.length, totalCandidates: candidates.length, @@ -764,10 +767,10 @@ function finalizeResult(result: MutableSearchResult): AgentSearchResult { return left.target.localeCompare(right.target); }); const followUps = [...result.followUps].sort(); - const boundedRankReasons = boundAgentList(rankReasons, MAX_RANK_REASONS_PER_RESULT); - const boundedEvidence = boundAgentList(evidence, MAX_EVIDENCE_PER_RESULT); - const boundedNeighbors = boundAgentList(neighbors, MAX_NEIGHBORS_PER_RESULT); - const boundedFollowUps = boundAgentList(followUps, MAX_FOLLOWUPS_PER_RESULT); + const boundedRankReasons = boundAgentList(rankReasons, AGENT_SEARCH_RANK_REASONS_PER_RESULT_LIMIT); + const boundedEvidence = boundAgentList(evidence, AGENT_SEARCH_EVIDENCE_PER_RESULT_LIMIT); + const boundedNeighbors = boundAgentList(neighbors, AGENT_SEARCH_NEIGHBORS_PER_RESULT_LIMIT); + const boundedFollowUps = boundAgentList(followUps, AGENT_SEARCH_FOLLOWUPS_PER_RESULT_LIMIT); return { handle: result.handle, diff --git a/src/cli/review.ts b/src/cli/review.ts index 1cf32ef0..94b08dc8 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -4,6 +4,12 @@ import type { CandidateTestFile } from "../impact/context.js"; import type { BuildReport } from "../indexer/types.js"; import { type GraphBuildOptions } from "../graphs/types.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; +import { + REVIEW_SUMMARY_CANDIDATES_PER_CONFIDENCE_LIMIT, + REVIEW_SUMMARY_CHANGED_FILE_LIMIT, + REVIEW_SUMMARY_SYMBOLS_PER_FILE_LIMIT, + REVIEW_SUMMARY_TASK_LIMIT, +} from "../presentation/bounds.js"; import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parseOptionalNonNegativeIntegerOption } from "./options.js"; @@ -67,10 +73,10 @@ function appendCandidateTestGroup( const matches = candidates.filter((candidate) => candidate.confidence === confidence); if (!matches.length) return 0; lines.push(title); - for (const candidate of matches.slice(0, 8)) { + for (const candidate of matches.slice(0, REVIEW_SUMMARY_CANDIDATES_PER_CONFIDENCE_LIMIT)) { lines.push(`- ${candidate.file}: ${candidate.reason}`); } - const remaining = matches.length - 8; + const remaining = matches.length - REVIEW_SUMMARY_CANDIDATES_PER_CONFIDENCE_LIMIT; if (remaining > 0) { lines.push(`- ... and ${remaining} more`); } @@ -102,12 +108,12 @@ function formatReviewSummary(report: Awaited symbol.name); + for (const file of report.changedFiles.slice(0, REVIEW_SUMMARY_CHANGED_FILE_LIMIT)) { + const symbolNames = file.symbols.slice(0, REVIEW_SUMMARY_SYMBOLS_PER_FILE_LIMIT).map((symbol) => symbol.name); const symbolSummary = symbolNames.length ? ` (${symbolNames.join(", ")})` : ""; lines.push(`- ${file.file}: ${file.status}${symbolSummary}`); } - const remainingFiles = report.changedFiles.length - 20; + const remainingFiles = report.changedFiles.length - REVIEW_SUMMARY_CHANGED_FILE_LIMIT; if (remainingFiles > 0) { lines.push(`- ... and ${remainingFiles} more`); } @@ -130,10 +136,10 @@ function formatReviewSummary(report: Awaited 0) { lines.push(`- ... and ${remainingTasks} more`); } diff --git a/src/presentation/bounds.ts b/src/presentation/bounds.ts new file mode 100644 index 00000000..a77408b1 --- /dev/null +++ b/src/presentation/bounds.ts @@ -0,0 +1,50 @@ +export type BoundedList = { + items: T[]; + omitted: number; +}; + +export const REVIEW_DEFAULT_CANDIDATE_TEST_LIMIT = 50; +export const REVIEW_SUMMARY_CHANGED_FILE_LIMIT = 20; +export const REVIEW_SUMMARY_SYMBOLS_PER_FILE_LIMIT = 5; +export const REVIEW_SUMMARY_CANDIDATES_PER_CONFIDENCE_LIMIT = 8; +export const REVIEW_SUMMARY_TASK_LIMIT = 8; + +export const AGENT_SEARCH_RESULT_LIMIT = 100; +export const AGENT_SEARCH_RANK_REASONS_PER_RESULT_LIMIT = 6; +export const AGENT_SEARCH_EVIDENCE_PER_RESULT_LIMIT = 5; +export const AGENT_SEARCH_NEIGHBORS_PER_RESULT_LIMIT = 12; +export const AGENT_SEARCH_FOLLOWUPS_PER_RESULT_LIMIT = 8; +export const AGENT_SEARCH_FORMAT_REASON_LIMIT = 3; + +export const AGENT_EXPLAIN_DEFAULT_DEPENDENCY_LIMIT = 20; +export const AGENT_EXPLAIN_DEFAULT_SNIPPET_LIMIT = 8; +export const AGENT_EXPLAIN_DEFAULT_SYMBOL_LIMIT = 50; +export const AGENT_EXPLAIN_MAX_DEPENDENCY_LIMIT = 100; +export const AGENT_EXPLAIN_MAX_SNIPPET_LIMIT = 50; +export const AGENT_EXPLAIN_MAX_SYMBOL_LIMIT = 200; +export const AGENT_EXPLAIN_FORMAT_SYMBOL_LIMIT = 8; +export const AGENT_EXPLAIN_FORMAT_FOLLOWUP_LIMIT = 8; +export const AGENT_EXPLAIN_FILE_SYMBOL_REF_LIMIT = 5; +export const AGENT_EXPLAIN_CHANGED_FILE_LIMIT = 20; +export const AGENT_EXPLAIN_REVIEW_TASK_LIMIT = 5; +export const AGENT_EXPLAIN_CANDIDATE_TEST_LIMIT = 10; +export const AGENT_EXPLAIN_REVIEW_CONTEXT_CANDIDATE_LIMIT = 5; + +export function boundList(items: readonly T[], limit: number): BoundedList { + const boundedItems = items.slice(0, limit); + return { + items: boundedItems, + omitted: countOmitted(items.length, boundedItems.length), + }; +} + +export function emptyBoundedList(): BoundedList { + return { + items: [], + omitted: 0, + }; +} + +export function countOmitted(total: number, visible: number): number { + return Math.max(0, total - visible); +} diff --git a/src/review/candidates.ts b/src/review/candidates.ts index 22afea2c..87215893 100644 --- a/src/review/candidates.ts +++ b/src/review/candidates.ts @@ -1,6 +1,7 @@ import { performance } from "node:perf_hooks"; import { listCandidateTestFiles, type CandidateTestFile } from "../impact/context.js"; import { type ProjectIndex } from "../indexer/types.js"; +import { REVIEW_DEFAULT_CANDIDATE_TEST_LIMIT } from "../presentation/bounds.js"; import type { FileId } from "../types.js"; import { normalizePath, toProjectRelativePath } from "../util/paths.js"; import type { ReviewOptions, ReviewTimingReport } from "../review.js"; @@ -50,9 +51,10 @@ export async function collectReviewCandidateTests(input: { reviewTimings?: ReviewTimingReport; }): Promise { const candidateStart = performance.now(); + const maxCandidates = input.appliedOptions.maxCandidates ?? REVIEW_DEFAULT_CANDIDATE_TEST_LIMIT; const candidateTests = mergeCandidateTestEntries( listCandidateTestFiles(input.index, input.changedFileList, input.changedSymbolIds, { - maxCandidates: input.appliedOptions.maxCandidates ?? 50, + maxCandidates, ...(input.appliedOptions.testPatterns ? { testPatterns: input.appliedOptions.testPatterns } : {}), projectRoot: input.projectRoot, }), @@ -74,7 +76,7 @@ export async function collectReviewCandidateTests(input: { if (fileCompare !== 0) return fileCompare; return left.reason.localeCompare(right.reason); }) - .slice(0, input.appliedOptions.maxCandidates ?? 50); + .slice(0, maxCandidates); if (input.reviewTimings) { input.reviewTimings.candidatesMs = Math.round(performance.now() - candidateStart); } From a0128aeb9fd31f70fd5bc3cbf994dac090fd9d62 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 18:42:46 -0400 Subject: [PATCH 15/25] Consolidate display path helpers --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/agent/normalize.ts | 4 ++-- src/cli.ts | 4 ++-- src/cli/graphQueries.ts | 18 ++++++++---------- src/cli/inspect.ts | 3 ++- src/cli/navigation.ts | 5 ++--- src/graphs/grep.ts | 6 +++--- src/graphs/symbol-render.ts | 7 ++++--- src/impact/path.ts | 4 ++-- src/impact/report-suggestions.ts | 6 ++---- src/mcp/server.ts | 4 ++-- src/review/candidates.ts | 4 ++-- src/review/deleted.ts | 4 ++-- src/review/summaries.ts | 4 ++-- src/util.ts | 1 + src/util/paths.ts | 5 +++++ tests/path-normalization.test.ts | 7 +++++++ 17 files changed, 49 insertions(+), 39 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index e87a4757..18876907 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -112,7 +112,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Target: named constants and a small presentation-bound helper for CLI summaries, review summaries, agent changed context, and impact pretty output. - Add tests that assert omission/truncation behavior and avoid silently changing output volume. -- [ ] Consolidate relative path display helpers. +- [x] Consolidate relative path display helpers. - Finding: many modules directly call `path.relative(...).replace(/\\/g, "/")` or local wrappers instead of `toProjectRelativePath` or a display helper. - Target: use one project-relative display helper for CLI, review, graph rendering, agent output, and index/cache reporting. - Add path-normalization regression coverage for POSIX, Windows-style absolute paths, and out-of-root paths. diff --git a/src/agent/normalize.ts b/src/agent/normalize.ts index 4300d9b1..3fec7024 100644 --- a/src/agent/normalize.ts +++ b/src/agent/normalize.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { normalizePath, toProjectRelativePath } from "../util/paths.js"; +import { normalizePath, toProjectDisplayPath, toProjectRelativePath } from "../util/paths.js"; import { quoteShellArg } from "./shell.js"; export type AgentFileSnapshot = { @@ -14,7 +14,7 @@ export function normalizeAgentFilePath(root: string, file: string): string { } export function normalizeAgentOutputPath(root: string, file: string): string { - return toProjectRelativePath(root, file) ?? normalizePath(file); + return toProjectDisplayPath(root, file); } export function resolveAgentSnapshotFile(snapshot: AgentFileSnapshot, candidate: string): string | null { diff --git a/src/cli.ts b/src/cli.ts index 91267be6..9212ef7e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,7 +44,7 @@ import { handleSqlCommand } from "./cli/sql.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "./config.js"; import { listChangedFiles } from "./util/git.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "./util/projectFiles.js"; -import { normalizePath, resolveFilePathFromRoot } from "./util/paths.js"; +import { normalizePath, resolveFilePathFromRoot, toProjectDisplayPath } from "./util/paths.js"; export { isCliDiscoveryRelativePathInside } from "./cli/context.js"; @@ -320,7 +320,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { if (deletedFiles.length) { writeStderrLine( `Skipping ${deletedFiles.length} deleted file(s) from git diff: ${deletedFiles - .map((file) => path.relative(projectRootFs, file) || file) + .map((file) => toProjectDisplayPath(projectRootFs, file) || file) .join(", ")}`, ); } diff --git a/src/cli/graphQueries.ts b/src/cli/graphQueries.ts index 967b46c9..662cf827 100644 --- a/src/cli/graphQueries.ts +++ b/src/cli/graphQueries.ts @@ -1,5 +1,3 @@ -import path from "node:path"; - import { buildProjectIndex as defaultBuildProjectIndex } from "../indexer/build-index.js"; import { getApiSurface } from "../indexer/symbols.js"; import { type BuildOptions, type ProjectIndex } from "../indexer/types.js"; @@ -14,7 +12,7 @@ import { } from "../graphs/queries.js"; import { type GraphBuildOptions } from "../graphs/types.js"; import type { Graph } from "../types.js"; -import { assertFilePathWithinRoot } from "../util/paths.js"; +import { assertFilePathWithinRoot, toProjectDisplayPath } from "../util/paths.js"; import { parseOptionalNonNegativeIntegerOption } from "./options.js"; export type GraphQueryCommand = "deps" | "rdeps" | "path" | "cycles" | "unresolved" | "apisurface"; @@ -130,7 +128,7 @@ async function handleDepsCommand(context: GraphQueryCommandContext): Promise path.relative(context.projectRootFs, entry)).join(" -> ")); + context.writeStdoutLine(pathResult.map((entry) => toProjectDisplayPath(context.projectRootFs, entry)).join(" -> ")); } else { context.writeStdoutLine(`No path found from ${fromArg} to ${toArg}`); } @@ -195,13 +193,13 @@ async function handleCyclesCommand(context: GraphQueryCommandContext): Promise path.relative(context.projectRootFs, entry)).join(" -> ")} -> ...`, + ` ${cycle.files.map((entry) => toProjectDisplayPath(context.projectRootFs, entry)).join(" -> ")} -> ...`, ); if (cycle.entryEdges.length) { context.writeStdoutLine(" Incoming edges:"); for (const edge of cycle.entryEdges) { context.writeStdoutLine( - ` ${path.relative(context.projectRootFs, edge.from)} -> ${path.relative(context.projectRootFs, edge.to)} (import ${edge.raw})`, + ` ${toProjectDisplayPath(context.projectRootFs, edge.from)} -> ${toProjectDisplayPath(context.projectRootFs, edge.to)} (import ${edge.raw})`, ); } } @@ -209,7 +207,7 @@ async function handleCyclesCommand(context: GraphQueryCommandContext): Promise ${path.relative(context.projectRootFs, edge.to)} (import ${edge.raw})`, + ` ${toProjectDisplayPath(context.projectRootFs, edge.from)} -> ${toProjectDisplayPath(context.projectRootFs, edge.to)} (import ${edge.raw})`, ); } } @@ -237,7 +235,7 @@ async function handleUnresolvedCommand(context: GraphQueryCommandContext): Promi context.writeStdoutLine(`- ${item.name} (imported by ${item.importers.length} files)`); if (context.hasFlag("--verbose")) { for (const imp of item.importers) { - context.writeStdoutLine(` ${path.relative(context.projectRootFs, imp.file)} (as "${imp.raw}")`); + context.writeStdoutLine(` ${toProjectDisplayPath(context.projectRootFs, imp.file)} (as "${imp.raw}")`); } } } @@ -256,7 +254,7 @@ async function handleApiSurfaceCommand(context: GraphQueryCommandContext): Promi context.writeStdoutLine(`API Surface for ${context.projectRootAbs}:`); for (const item of apiSurface) { - context.writeStdoutLine(` ${path.relative(context.projectRootFs, item.file)}:`); + context.writeStdoutLine(` ${toProjectDisplayPath(context.projectRootFs, item.file)}:`); for (const exp of item.exports) { context.writeStdoutLine(` - ${exp.exportedAs} (${exp.kind})`); } diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts index 56a19088..a56ee9a5 100644 --- a/src/cli/inspect.ts +++ b/src/cli/inspect.ts @@ -14,6 +14,7 @@ import { } from "../native/treeSitterNative.js"; import type { Graph } from "../types.js"; import { supportForFile } from "../languages.js"; +import { toProjectDisplayPath } from "../util/paths.js"; import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parseCacheModeOption, parsePositiveIntegerOption } from "./options.js"; @@ -323,7 +324,7 @@ export async function handleHotspotsCommand(context: InspectCommandContext): Pro context.writeStdoutLine("Top hotspots (files with high fan-in/out):"); for (const item of hotspots) { context.writeStdoutLine( - `- ${path.relative(context.projectRootFs, item.file)} (fan-in: ${item.fanIn}, fan-out: ${item.fanOut}, score: ${item.score.toFixed(1)})`, + `- ${toProjectDisplayPath(context.projectRootFs, item.file)} (fan-in: ${item.fanIn}, fan-out: ${item.fanOut}, score: ${item.score.toFixed(1)})`, ); } } diff --git a/src/cli/navigation.ts b/src/cli/navigation.ts index 56a6b3b9..b5ec25a1 100644 --- a/src/cli/navigation.ts +++ b/src/cli/navigation.ts @@ -1,8 +1,7 @@ -import path from "node:path"; import { buildProjectIndex } from "../indexer/build-index.js"; import { findReferences, goToDefinition } from "../indexer/navigation.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; -import { assertFilePathWithinRoot } from "../util/paths.js"; +import { assertFilePathWithinRoot, toProjectDisplayPath } from "../util/paths.js"; import { type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { parsePositiveIntegerOption } from "./options.js"; @@ -149,7 +148,7 @@ export async function handleRefsCommand(context: NavigationCommandContext): Prom } if (res.status === "ok") { for (const r of res.references) { - const rel = path.relative(context.projectRootFs, r.file); + const rel = toProjectDisplayPath(context.projectRootFs, r.file); const { line: refLine, column: refColumn } = r.range.start; context.writeStdoutLine(`${rel}:${refLine}:${refColumn}`); } diff --git a/src/graphs/grep.ts b/src/graphs/grep.ts index a48f1e41..52067074 100644 --- a/src/graphs/grep.ts +++ b/src/graphs/grep.ts @@ -1,8 +1,8 @@ import fsp from "node:fs/promises"; -import path from "node:path"; import { prepareSourceInput } from "../languages/filePrep.js"; import { logWithLevel } from "../logging.js"; import { getUnifiedQueryExecution } from "../native/treeSitterNative.js"; +import { toProjectDisplayPath } from "../util/paths.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; export type AstGrepHit = { @@ -44,7 +44,7 @@ export async function astGrep( for (const match of matches) { for (const capture of match.captures) { hits.push({ - file: path.relative(projectRoot, file).replace(/\\/g, "/"), + file: toProjectDisplayPath(projectRoot, file), capture: capture.name, line: capture.start.row + 1, column: capture.start.column + 1, @@ -95,7 +95,7 @@ export async function textGrep( continue; } - const relativeFile = path.relative(projectRoot, file).replace(/\\/g, "/"); + const relativeFile = toProjectDisplayPath(projectRoot, file); const lines = source.split(/\r?\n/); for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { if (hits.length >= maxHits) break; diff --git a/src/graphs/symbol-render.ts b/src/graphs/symbol-render.ts index 4666b3c1..5c0b78b2 100644 --- a/src/graphs/symbol-render.ts +++ b/src/graphs/symbol-render.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { Graph } from "../types.js"; +import { toProjectDisplayPath } from "../util/paths.js"; import { dotLabel, mermaidLabel } from "./render.js"; type SymbolNodeLike = { @@ -21,7 +22,7 @@ type SymbolGraphLike = { }; function symbolDisplayLabel(node: SymbolNodeLike, projectRoot?: string): string { - const relativeFile = projectRoot ? path.relative(projectRoot, node.file).replace(/\\/g, "/") : node.file; + const relativeFile = toProjectDisplayPath(projectRoot, node.file); const base = path.basename(relativeFile); if (node.kind === "import") return `${base}:${node.name} (import)`; if (node.kind === "namespaceImport") return `${base}:${node.name} (namespace)`; @@ -84,7 +85,7 @@ export function graphToMermaidSymbolsWithFiles(sg: SymbolGraphLike, fg: Graph, p const fileIdOf = new Map(); const fileNodeMeta = new Map(); let fileIndex = 0; - const fileLabel = (file: string) => (projectRoot ? path.relative(projectRoot, file).replace(/\\/g, "/") : file); + const fileLabel = (file: string) => toProjectDisplayPath(projectRoot, file); const ensureFile = (file: string) => { if (!fileIdOf.has(file)) { const id = `f${fileIndex++}`; @@ -151,7 +152,7 @@ export function graphToDOTSymbolsWithFiles(sg: SymbolGraphLike, fg: Graph, proje const fileIdOf = new Map(); const fileNodeMeta = new Map(); let fileIndex = 0; - const fileLabel = (file: string) => (projectRoot ? path.relative(projectRoot, file).replace(/\\/g, "/") : file); + const fileLabel = (file: string) => toProjectDisplayPath(projectRoot, file); const ensureFile = (file: string) => { if (!fileIdOf.has(file)) { const id = `f${fileIndex++}`; diff --git a/src/impact/path.ts b/src/impact/path.ts index 46f94f90..13e1c329 100644 --- a/src/impact/path.ts +++ b/src/impact/path.ts @@ -1,7 +1,7 @@ import type { FileId } from "../types.js"; import type { FileChange } from "./types.js"; import pm from "picomatch"; -import { isFilePathWithinRoot, normalizePath, resolveFilePathFromRoot, toProjectRelativePath } from "../util/paths.js"; +import { isFilePathWithinRoot, normalizePath, resolveFilePathFromRoot, toProjectDisplayPath } from "../util/paths.js"; export function normalizeImpactFilePath(projectRoot: string, filePath: string): string { return normalizePath(resolveFilePathFromRoot(projectRoot, filePath)); @@ -33,7 +33,7 @@ export function normalizeImpactFileChange(projectRoot: string, change: FileChang } export function toImpactReportFilePath(projectRoot: string, filePath: string): string { - return toProjectRelativePath(projectRoot, filePath) ?? normalizePath(filePath); + return toProjectDisplayPath(projectRoot, filePath); } export function createImpactIgnoreMatcher( diff --git a/src/impact/report-suggestions.ts b/src/impact/report-suggestions.ts index d244c9a5..b14f497b 100644 --- a/src/impact/report-suggestions.ts +++ b/src/impact/report-suggestions.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { SymbolKind, type ProjectIndex } from "../indexer/types.js"; import { findReferences } from "../indexer/navigation.js"; -import { resolveFilePathFromRoot } from "../util/paths.js"; +import { resolveFilePathFromRoot, toProjectDisplayPath } from "../util/paths.js"; import { mapLimit } from "../util/concurrency.js"; import { listCandidateTestFiles } from "./context.js"; import { collectHunkLineText, collectRemovedLines } from "./hunks.js"; @@ -115,9 +115,7 @@ function classifyConfigImpact( const aliases = collectTsconfigPathAliases(change); const blastRadius = collectTsconfigBlastRadius(index, aliases); if (blastRadius.aliases.length) { - const relImporters = blastRadius.importers - .slice(0, 5) - .map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")); + const relImporters = blastRadius.importers.slice(0, 5).map((file) => toProjectDisplayPath(projectRoot, file)); const importerSummary = blastRadius.importers.length ? `Likely impacted importer files: ${relImporters.join(", ")}${ blastRadius.importers.length > relImporters.length ? ", ..." : "" diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9fddd1a8..a9402e8b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -23,7 +23,7 @@ import { getDependencies, getReverseDependencies, getShortestPath } from "../gra import { findReferences, goToDefinition } from "../indexer/navigation.js"; import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../review.js"; import { queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; -import { normalizePath, toProjectRelativePath } from "../util/paths.js"; +import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession } from "../agent/session.js"; import type { AgentSession } from "../agent/session.js"; import { @@ -149,7 +149,7 @@ export function createCodegraphMcpHandlers(options: CodegraphMcpServerOptions): const realRoot = fs.realpath(root); let sqlitePath = options.artifactPath ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; - const relative = (file: string): string => toProjectRelativePath(root, file) ?? normalizePath(path.resolve(file)); + const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); diff --git a/src/review/candidates.ts b/src/review/candidates.ts index 87215893..55b3b593 100644 --- a/src/review/candidates.ts +++ b/src/review/candidates.ts @@ -3,12 +3,12 @@ import { listCandidateTestFiles, type CandidateTestFile } from "../impact/contex import { type ProjectIndex } from "../indexer/types.js"; import { REVIEW_DEFAULT_CANDIDATE_TEST_LIMIT } from "../presentation/bounds.js"; import type { FileId } from "../types.js"; -import { normalizePath, toProjectRelativePath } from "../util/paths.js"; +import { toProjectDisplayPath } from "../util/paths.js"; import type { ReviewOptions, ReviewTimingReport } from "../review.js"; import { listDirectDeletedFileTestImporters } from "./deleted.js"; function relativePath(root: string, file: string): string { - return toProjectRelativePath(root, file) ?? normalizePath(file); + return toProjectDisplayPath(root, file); } function comparePaths(left: string, right: string): number { diff --git a/src/review/deleted.ts b/src/review/deleted.ts index 1ae1e357..7bea1450 100644 --- a/src/review/deleted.ts +++ b/src/review/deleted.ts @@ -16,7 +16,7 @@ import { loadWorkspaceConfig, type WorkspaceConfig, } from "../util/workspace.js"; -import { normalizePath, toProjectRelativePath } from "../util/paths.js"; +import { normalizePath, toProjectDisplayPath } from "../util/paths.js"; import type { GraphBuildOptions } from "../graphs/types.js"; const execFileAsync = promisify(execFile); @@ -29,7 +29,7 @@ export type DeletedFileSnapshot = { type ReviewableExportEntry = Exclude; function relativePath(root: string, file: string): string { - return toProjectRelativePath(root, file) ?? normalizePath(file); + return toProjectDisplayPath(root, file); } function normalizeSpecifierBase(fromFile: string, spec: string): string { diff --git a/src/review/summaries.ts b/src/review/summaries.ts index d104e32f..cad7b3f0 100644 --- a/src/review/summaries.ts +++ b/src/review/summaries.ts @@ -9,7 +9,7 @@ import { locateChangedSymbolsWithLines, mapChangedLinesToSymbols } from "../impa import type { Hunk } from "../impact/types.js"; import type { FileId, Range } from "../types.js"; import { mapLimit } from "../util/concurrency.js"; -import { normalizePath, toProjectRelativePath } from "../util/paths.js"; +import { toProjectDisplayPath } from "../util/paths.js"; import type { ReviewDiagnostics, ReviewTimingReport } from "../review.js"; import type { DeletedFileSnapshot } from "./deleted.js"; import { isRiskRelevantSymbolMappingFile } from "./risk.js"; @@ -45,7 +45,7 @@ export type ReviewChangedFileSummaries = { type ReviewableExportEntry = Exclude; function relativePath(root: string, file: string): string { - return toProjectRelativePath(root, file) ?? normalizePath(file); + return toProjectDisplayPath(root, file); } function sortSymbols(symbols: SymbolDef[]): SymbolDef[] { diff --git a/src/util.ts b/src/util.ts index c946851e..226705f4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -24,6 +24,7 @@ export { normalizePath, normalizeResolutionHints, resolveFilePathFromRoot, + toProjectDisplayPath, toProjectRelativePath, } from "./util/paths.js"; export { diff --git a/src/util/paths.ts b/src/util/paths.ts index e8f2ea0e..92b02450 100644 --- a/src/util/paths.ts +++ b/src/util/paths.ts @@ -84,6 +84,11 @@ export function toProjectRelativePath(projectRoot: string, filePath: string): st return normalizePath(path.relative(normalizedRoot, normalizedFile)); } +export function toProjectDisplayPath(projectRoot: string | undefined, filePath: string): string { + if (!projectRoot) return normalizePath(filePath); + return toProjectRelativePath(projectRoot, filePath) ?? normalizePath(filePath); +} + export function normalizeResolutionHints(hints?: string[]): string[] { const out: string[] = []; const seen = new Set(); diff --git a/tests/path-normalization.test.ts b/tests/path-normalization.test.ts index 5cfa2588..bc3ef552 100644 --- a/tests/path-normalization.test.ts +++ b/tests/path-normalization.test.ts @@ -6,6 +6,7 @@ import { isFilePathWithinRoot, normalizePath, normalizeResolutionHints, + toProjectDisplayPath, toProjectRelativePath, } from "../src/util.js"; import { normalizeImpactFilePath } from "../src/impact/path.js"; @@ -64,6 +65,12 @@ describe("cross-platform path normalization", () => { expect(normalizePath("src/feature/main.ts")).toBe("src/feature/main.ts"); }); + it("formats project display paths as relative slash-normalized paths when possible", () => { + expect(toProjectDisplayPath("/workspace/codegraph", "/workspace/codegraph/src/main.ts")).toBe("src/main.ts"); + expect(toProjectDisplayPath("/workspace/codegraph", "C:/repo/src/main.ts")).toBe("C:/repo/src/main.ts"); + expect(toProjectDisplayPath(undefined, String.raw`src\main.ts`)).toBe("src/main.ts"); + }); + it("asserts project-root containment with label-specific errors", () => { const root = "C:/workspace/codegraph"; From 34e719a069488b976f2b35fcf4fe90a8483b74e7 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 18:52:36 -0400 Subject: [PATCH 16/25] Split build index phases --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/indexer/build-index.ts | 317 +++---------------------------- src/indexer/build-manifest.ts | 55 ++++++ src/indexer/build-workers.ts | 142 ++++++++++++++ src/indexer/finalize.ts | 39 ++++ src/indexer/incremental-plan.ts | 59 ++++++ src/indexer/parsed-cache.ts | 39 ++++ tests/cache-invalidation.test.ts | 8 + 8 files changed, 368 insertions(+), 293 deletions(-) create mode 100644 src/indexer/build-manifest.ts create mode 100644 src/indexer/build-workers.ts create mode 100644 src/indexer/finalize.ts create mode 100644 src/indexer/incremental-plan.ts create mode 100644 src/indexer/parsed-cache.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 18876907..3863dd21 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -119,7 +119,7 @@ No dependency cycles were reported. The highest remaining concentration is in: ### Performance And Accuracy -- [ ] Review `buildProjectIndex` phase boundaries for cache/incremental complexity. +- [x] Review `buildProjectIndex` phase boundaries for cache/incremental complexity. - Current shape: `src/indexer/build-index.ts` still handles worker setup, parsed cache retention, file signatures, manifest snapshots, graph finalization, and incremental entry points. - Target split: - worker setup/teardown diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index fab74a4d..a3714684 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -3,10 +3,9 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { supportForFile, type LanguageSupport } from "../languages.js"; import { loadWorkspaceConfig, resolveWorkspacePackage } from "../util/workspace.js"; -import { listProjectFiles, discoverProjectFiles } from "../util/projectFiles.js"; +import { listProjectFiles } from "../util/projectFiles.js"; import { getGitHead, isGitRepo, getGitBlobHashes, listChangedFiles } from "../util/git.js"; import { clearImportResolutionCaches, resolveSpecifier } from "../util/resolution.js"; -import { stringifyUnknown } from "../util/ast.js"; import { assertFilePathWithinRoot, normalizePath } from "../util/paths.js"; import { mapLimit } from "../util/concurrency.js"; import { logWithLevel, type LogLevel } from "../logging.js"; @@ -14,26 +13,15 @@ import { collectGraph } from "../graph-builder.js"; import { collectEdgesForFile } from "../graph-edge-collector.js"; import { buildGraphAdjacency } from "../graphs/adjacency.js"; import type { FallbackImportExtractionEvent } from "../graphs/specifiers.js"; -import type { GraphCacheEntry, GraphBuildOptions } from "../graphs/types.js"; +import type { GraphBuildOptions, GraphCacheEntry } from "../graphs/types.js"; import { isGraphOnlyLanguage } from "../documentLinks.js"; -import { - attemptParsePreparedFileContext, - prepareFileForIndexing, - type ParsedFileContext, - type PreparedFileContext, -} from "./parse-context.js"; +import { attemptParsePreparedFileContext, type ParsedFileContext } from "./parse-context.js"; import { collectImportsForFile } from "./imports.js"; import { collectLocalsAndExportsFromSource } from "./locals-and-exports.js"; import { compareEdges, edgeKey, toRelativeEdge } from "./shared.js"; import { buildBloomFilterFromSource } from "../util/bloomFilter.js"; -import { initNativeBackendReport, recordNativeExecutionOutcome } from "../native/nativeBackendReport.js"; -import { - getCachedNormalizedQuery, - isNativeRequiredUnavailableError, - isNativeTreeSitterAvailable, - type NativeRuntimeMode, -} from "../native/treeSitterNative.js"; -import type { NativeExtractResult, NativeExtractTask } from "../worker/nativeExtractWorker.js"; +import { initNativeBackendReport } from "../native/nativeBackendReport.js"; +import { isNativeRequiredUnavailableError } from "../native/treeSitterNative.js"; import type { JsLanguage, SyntaxTreeLike } from "../languages/types.js"; import type { Edge, FileId, Graph } from "../types.js"; import { @@ -49,19 +37,15 @@ import { initFileReport, initManifestReport, loadManifest, - MANIFEST_VERSION, normalizeGraphOptions, normalizeIndexedFileInputs, recordConfigHashResult, recordFileFailure, sanitizeManifestEntriesForRoot, - summarizeBuildOptions, tryLoadFromCache, verifyManifestEntries, - writeManifest, writeToCache, type FileSignature, - type IndexManifest, type ManifestFileEntry, } from "./build-cache.js"; import { @@ -70,18 +54,31 @@ import { type GraphDeltaReport, type ImportBinding, type IncrementalBuildOptions, - type ManifestReport, type ModuleIndex, type NativeBackendFallbackReason, type ParserBackendDegradationReport, type ProjectIndex, type SymbolDef, SymbolKind, - type WorkerPoolReport, } from "./types.js"; import { isJsFallbackUnavailableError, isJsSyntaxTree } from "../jsFallback.js"; import { isUnsupportedParserInputError } from "../languages/filePrep.js"; import { buildSqlFactCache, buildSqlModuleIndex, sqlCorpusSignature, type SqlFactCache } from "../sql/sourceGraph.js"; +import { finalizeProjectIndex } from "./finalize.js"; +import { toManifestFileEntry, writeIndexManifestSnapshot } from "./build-manifest.js"; +import { + prepareFileContextForBuild, + setupWorkerPool, + teardownWorkerPool, + type WorkerPoolSetupResult, +} from "./build-workers.js"; +import { + buildIncrementalGitDiffOptions, + collectDeletedTrackedFileDependents, + isMissingGitRevisionError, + partitionTrackedManifestFiles, +} from "./incremental-plan.js"; +import { parsedCacheMaxEntries, setParsedCacheEntry } from "./parsed-cache.js"; type IndexedFileGraphContext = { source: string; @@ -147,143 +144,6 @@ function createEmptyModuleIndex(file: string): ModuleIndex { return { file, exports: [], imports: [], locals: [] }; } -function isMissingGitRevisionError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes("Invalid revision range") || - message.includes("bad revision") || - message.includes("unknown revision") || - message.includes("ambiguous argument") - ); -} - -function isSFCFile(filePath: string): boolean { - return filePath.endsWith(".vue") || filePath.endsWith(".svelte") || filePath.endsWith(".astro"); -} - -type WorkerPoolSetupResult = { - pool: import("piscina").Piscina | null; - report: WorkerPoolReport | undefined; - startTime: number; -}; - -function buildWorkerTask(filePath: string, sup: LanguageSupport): NativeExtractTask { - return { - filePath, - languageId: sup.id, - importsQuery: getCachedNormalizedQuery(sup, "imports"), - exportsQuery: getCachedNormalizedQuery(sup, "exports"), - localsQuery: getCachedNormalizedQuery(sup, "locals"), - importBindingsQuery: getCachedNormalizedQuery(sup, "importBindings"), - }; -} - -function workerResultToPrepared( - result: NativeExtractResult, - sup: LanguageSupport, - filePath: string, -): PreparedFileContext { - return { - file: filePath, - source: result.source, - sup, - nativeQueries: result.nativeResults, - ...(result.fallbackReason ? { nativeFallbackReason: result.fallbackReason } : {}), - ...(result.error ? { nativeError: result.error } : {}), - }; -} - -async function setupWorkerPool(opts: BuildOptions | undefined): Promise { - const shouldUseWorkers = - !!opts?.useNativeWorkers && opts?.native !== "off" && isNativeTreeSitterAvailable(opts?.native); - const report: WorkerPoolReport | undefined = opts?.useNativeWorkers - ? { - enabled: shouldUseWorkers, - threads: 0, - tasksSubmitted: 0, - tasksFailed: 0, - } - : undefined; - let pool: import("piscina").Piscina | null = null; - if (shouldUseWorkers) { - try { - const { createNativeWorkerPool } = await import("../worker/nativeWorkerPool.js"); - const createdPool = createNativeWorkerPool({ - threads: opts.nativeThreads, - }); - pool = createdPool; - if (report) { - report.threads = (createdPool.options as { maxThreads?: number }).maxThreads ?? 0; - } - } catch (error) { - pool = null; - if (report) { - report.enabled = false; - report.startupError = stringifyUnknown(error); - } - } - } - return { pool, report, startTime: pool ? performance.now() : 0 }; -} - -async function teardownWorkerPool(setup: WorkerPoolSetupResult, buildReport: BuildReport | undefined): Promise { - if (setup.pool) { - if (setup.report) { - setup.report.wallClockMs = Math.round(performance.now() - setup.startTime); - } - try { - await setup.pool.destroy(); - } catch { - // non-fatal - } - setup.pool = null; - } - if (buildReport && setup.report) { - buildReport.workerPool = setup.report; - } -} - -async function prepareFileContextForBuild( - file: string, - support: LanguageSupport, - opts: BuildOptions | undefined, - workerSetup: WorkerPoolSetupResult, - report: BuildReport | undefined, -): Promise { - let prepared: PreparedFileContext; - if (workerSetup.pool && !isSFCFile(file)) { - if (workerSetup.report) workerSetup.report.tasksSubmitted++; - try { - const workerResult: NativeExtractResult = await workerSetup.pool.run(buildWorkerTask(file, support)); - prepared = workerResultToPrepared(workerResult, support, file); - } catch (error) { - if (isNativeRequiredUnavailableError(error)) throw error; - if (workerSetup.report) workerSetup.report.tasksFailed++; - if (workerSetup.report) { - workerSetup.report.errors ??= []; - if (workerSetup.report.errors.length < 20) { - workerSetup.report.errors.push({ - file, - message: stringifyUnknown(error), - }); - } - } - prepared = await prepareFileForIndexing(file, opts?.native); - } - } else { - prepared = await prepareFileForIndexing(file, opts?.native); - } - recordNativeExecutionOutcome(report, { - file, - support: prepared.sup, - languageId: prepared.sup.id, - results: prepared.nativeQueries, - ...(prepared.nativeFallbackReason ? { fallbackReason: prepared.nativeFallbackReason } : {}), - ...(prepared.nativeError ? { error: prepared.nativeError } : {}), - }); - return prepared; -} - async function resolveCrossModuleSymbolExports( file: string, mod: ModuleIndex, @@ -313,21 +173,6 @@ async function resolveCrossModuleSymbolExports( } } -function setParsedCacheEntry( - parsedMap: Map, - file: string, - entry: ParsedFileContext, - maxEntries: number, -): void { - if (parsedMap.has(file)) parsedMap.delete(file); - parsedMap.set(file, entry); - while (parsedMap.size > maxEntries) { - const oldest = parsedMap.keys().next().value; - if (!oldest) break; - parsedMap.delete(oldest); - } -} - async function buildIndexedModuleForFile(args: { file: string; support: LanguageSupport; @@ -567,10 +412,6 @@ function buildConcurrency(opts: BuildOptions | undefined): number { return Math.max(1, Math.min(Number(opts?.threads || 0) || 8, 64)); } -function parsedCacheMaxEntries(opts: BuildOptions | undefined): number { - return Math.max(1, opts?.parsedCacheMaxEntries ?? 1024); -} - async function prepareFileSignatures(args: { files: string[]; opts: BuildOptions | undefined; @@ -587,96 +428,6 @@ async function prepareFileSignatures(args: { return new Map(entries); } -function toManifestFileEntry(entry: GraphCacheEntry): ManifestFileEntry | undefined { - if (!entry.sig) return undefined; - return { - sig: entry.sig, - ...(entry.gitSig ? { gitSig: entry.gitSig } : {}), - ...(entry.sqlCorpusSig ? { sqlCorpusSig: entry.sqlCorpusSig } : {}), - edges: entry.edges, - }; -} - -async function writeIndexManifestSnapshot(args: { - projectRoot: string; - opts: BuildOptions | undefined; - graphOptions: GraphBuildOptions; - files: Map | Record; - timings: BuildReport["timings"] | undefined; - manifestReport: ManifestReport | undefined; - allowEmpty?: boolean; -}): Promise { - const files = args.files instanceof Map ? Object.fromEntries(args.files) : args.files; - if (!Object.keys(files).length && !args.allowEmpty) return; - const writeManifestStart = performance.now(); - const lastCommit = await getGitHead(args.projectRoot); - const configHashResult = await computeConfigHash(args.projectRoot, args.opts?.logLevel); - const configHash = recordConfigHashResult(args.manifestReport, configHashResult, args.opts?.logLevel); - const manifestData: IndexManifest = { - version: MANIFEST_VERSION, - projectRoot: path.resolve(args.projectRoot).replace(/\\/g, "/"), - updatedAt: Date.now(), - ...(lastCommit ? { lastCommit } : {}), - ...(configHash ? { configHash } : {}), - graphOptions: args.graphOptions, - buildOptions: summarizeBuildOptions(args.opts), - files, - }; - await writeManifest(args.projectRoot, args.opts, manifestData); - if (args.timings) { - args.timings.writeManifestMs = Math.round(performance.now() - writeManifestStart); - } -} - -function retainedParsedCache( - parsedMap: Map, - opts: BuildOptions | undefined, -): Map | undefined { - const keepParsed = opts?.keepParsed ?? false; - const maxParsedEntries = parsedCacheMaxEntries(opts); - if (!keepParsed) { - parsedMap.clear(); - return undefined; - } - while (parsedMap.size > maxParsedEntries) { - const oldest = parsedMap.keys().next().value; - if (!oldest) break; - parsedMap.delete(oldest); - } - return parsedMap; -} - -async function finalizeProjectIndex(args: { - projectRoot: string; - normalizedProjectRoot: string; - opts: BuildOptions | undefined; - timings: BuildReport["timings"] | undefined; - totalStart: number; - graph: Graph; - modules: Map; - parsedMap: Map; - bloomFilterCache: import("../util/bloomFilter.js").BloomFilterCache | undefined; -}): Promise { - if (args.timings) args.timings.totalMs = Math.round(performance.now() - args.totalStart); - const projectFiles = await discoverProjectFiles(args.projectRoot, { - ...(args.opts?.logLevel ? { logLevel: args.opts.logLevel } : {}), - }); - const parsed = retainedParsedCache(args.parsedMap, args.opts); - return { - graph: args.graph, - graphAdjacency: buildGraphAdjacency(args.graph), - modules: args.modules, - byFile: args.modules, - projectRoot: args.normalizedProjectRoot, - ...(args.opts?.native ? { nativeMode: args.opts.native } : {}), - exportCache: new Map(), - scopeCache: new Map(), - ...(parsed ? { parsed } : {}), - ...(args.bloomFilterCache ? { bloomFilters: args.bloomFilterCache } : {}), - projectFiles, - }; -} - async function buildProjectIndexFromExport( projectRoot: string, opts?: BuildOptions, @@ -1108,18 +859,12 @@ export async function buildProjectIndexIncremental( } } const trackedEntries = sanitizeManifestEntriesForRoot(projectRoot, manifest.files); - const trackedFileList = Object.keys(trackedEntries); - const trackedFiles = new Set(trackedFileList.filter((file) => fs.existsSync(file))); - const deletedTrackedFiles = new Set(trackedFileList.filter((file) => !fs.existsSync(file))); + const { trackedFiles, deletedTrackedFiles } = partitionTrackedManifestFiles(trackedEntries); const fileReport = initFileReport(report); if (fileReport) fileReport.total = trackedFiles.size; const explicitFiles = normalizeIndexedFileInputs(projectRoot, opts?.files ?? [], "Incremental file"); const needsGitScan = !!opts?.gitBase || !!opts?.changedSince; - const gitOpts: { base?: string; head?: string; changedSince?: string } = {}; - if (opts?.gitBase) gitOpts.base = opts.gitBase; - if (opts?.gitHead) gitOpts.head = opts.gitHead; - if (!opts?.gitBase && opts?.changedSince) gitOpts.changedSince = opts.changedSince; - const gitFiles = needsGitScan ? await listChangedFiles(projectRoot, gitOpts) : []; + const gitFiles = needsGitScan ? await listChangedFiles(projectRoot, buildIncrementalGitDiffOptions(opts)) : []; const allFiles = new Set([ ...trackedFiles, ...explicitFiles.filter((file) => fs.existsSync(file)), @@ -1128,15 +873,7 @@ export async function buildProjectIndexIncremental( ]); if (fileReport) fileReport.total = allFiles.size; const workspaceConfig = await loadWorkspaceConfig(projectRoot); - const dependentFilesOfDeletedTracked = new Set(); - if (deletedTrackedFiles.size > 0) { - for (const [file, entry] of Object.entries(trackedEntries)) { - if (deletedTrackedFiles.has(file)) continue; - if (entry.edges.some((edge) => edge.to.type === "file" && deletedTrackedFiles.has(edge.to.path))) { - dependentFilesOfDeletedTracked.add(file); - } - } - } + const dependentFilesOfDeletedTracked = collectDeletedTrackedFileDependents(trackedEntries, deletedTrackedFiles); if (allFiles.size === 0) { await writeIndexManifestSnapshot({ projectRoot, @@ -1355,12 +1092,8 @@ export async function buildGraphDelta(projectRoot: string, opts?: IncrementalBui fs.existsSync(file), ); const needsGitScan = !!opts?.gitBase || !!opts?.changedSince; - const gitOpts: { base?: string; head?: string; changedSince?: string } = {}; - if (opts?.gitBase) gitOpts.base = opts.gitBase; - if (opts?.gitHead) gitOpts.head = opts.gitHead; - if (!opts?.gitBase && opts?.changedSince) gitOpts.changedSince = opts.changedSince; - const gitFiles = needsGitScan ? await listChangedFiles(projectRoot, gitOpts) : []; - const trackedFiles = new Set(Object.keys(trackedEntries).filter((file) => fs.existsSync(file))); + const gitFiles = needsGitScan ? await listChangedFiles(projectRoot, buildIncrementalGitDiffOptions(opts)) : []; + const { trackedFiles } = partitionTrackedManifestFiles(trackedEntries); const gitAvailable = await isGitRepo(projectRoot); const currentHead = gitAvailable ? await getGitHead(projectRoot) : null; const hasExplicitGitRange = !!opts?.gitBase || !!opts?.gitHead; diff --git a/src/indexer/build-manifest.ts b/src/indexer/build-manifest.ts new file mode 100644 index 00000000..34112168 --- /dev/null +++ b/src/indexer/build-manifest.ts @@ -0,0 +1,55 @@ +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { getGitHead } from "../util/git.js"; +import { + computeConfigHash, + MANIFEST_VERSION, + recordConfigHashResult, + summarizeBuildOptions, + writeManifest, + type IndexManifest, + type ManifestFileEntry, +} from "./build-cache.js"; +import type { GraphCacheEntry, GraphBuildOptions } from "../graphs/types.js"; +import type { BuildOptions, BuildReport, ManifestReport } from "./types.js"; + +export function toManifestFileEntry(entry: GraphCacheEntry): ManifestFileEntry | undefined { + if (!entry.sig) return undefined; + return { + sig: entry.sig, + ...(entry.gitSig ? { gitSig: entry.gitSig } : {}), + ...(entry.sqlCorpusSig ? { sqlCorpusSig: entry.sqlCorpusSig } : {}), + edges: entry.edges, + }; +} + +export async function writeIndexManifestSnapshot(args: { + projectRoot: string; + opts: BuildOptions | undefined; + graphOptions: GraphBuildOptions; + files: Map | Record; + timings: BuildReport["timings"] | undefined; + manifestReport: ManifestReport | undefined; + allowEmpty?: boolean; +}): Promise { + const files = args.files instanceof Map ? Object.fromEntries(args.files) : args.files; + if (!Object.keys(files).length && !args.allowEmpty) return; + const writeManifestStart = performance.now(); + const lastCommit = await getGitHead(args.projectRoot); + const configHashResult = await computeConfigHash(args.projectRoot, args.opts?.logLevel); + const configHash = recordConfigHashResult(args.manifestReport, configHashResult, args.opts?.logLevel); + const manifestData: IndexManifest = { + version: MANIFEST_VERSION, + projectRoot: path.resolve(args.projectRoot).replace(/\\/g, "/"), + updatedAt: Date.now(), + ...(lastCommit ? { lastCommit } : {}), + ...(configHash ? { configHash } : {}), + graphOptions: args.graphOptions, + buildOptions: summarizeBuildOptions(args.opts), + files, + }; + await writeManifest(args.projectRoot, args.opts, manifestData); + if (args.timings) { + args.timings.writeManifestMs = Math.round(performance.now() - writeManifestStart); + } +} diff --git a/src/indexer/build-workers.ts b/src/indexer/build-workers.ts new file mode 100644 index 00000000..e84e27e7 --- /dev/null +++ b/src/indexer/build-workers.ts @@ -0,0 +1,142 @@ +import { performance } from "node:perf_hooks"; +import type { LanguageSupport } from "../languages.js"; +import { stringifyUnknown } from "../util/ast.js"; +import { recordNativeExecutionOutcome } from "../native/nativeBackendReport.js"; +import { + getCachedNormalizedQuery, + isNativeRequiredUnavailableError, + isNativeTreeSitterAvailable, +} from "../native/treeSitterNative.js"; +import type { NativeExtractResult, NativeExtractTask } from "../worker/nativeExtractWorker.js"; +import { prepareFileForIndexing, type PreparedFileContext } from "./parse-context.js"; +import type { BuildOptions, BuildReport, WorkerPoolReport } from "./types.js"; + +export type WorkerPoolSetupResult = { + pool: import("piscina").Piscina | null; + report: WorkerPoolReport | undefined; + startTime: number; +}; + +function isSFCFile(filePath: string): boolean { + return filePath.endsWith(".vue") || filePath.endsWith(".svelte") || filePath.endsWith(".astro"); +} + +function buildWorkerTask(filePath: string, sup: LanguageSupport): NativeExtractTask { + return { + filePath, + languageId: sup.id, + importsQuery: getCachedNormalizedQuery(sup, "imports"), + exportsQuery: getCachedNormalizedQuery(sup, "exports"), + localsQuery: getCachedNormalizedQuery(sup, "locals"), + importBindingsQuery: getCachedNormalizedQuery(sup, "importBindings"), + }; +} + +function workerResultToPrepared( + result: NativeExtractResult, + sup: LanguageSupport, + filePath: string, +): PreparedFileContext { + return { + file: filePath, + source: result.source, + sup, + nativeQueries: result.nativeResults, + ...(result.fallbackReason ? { nativeFallbackReason: result.fallbackReason } : {}), + ...(result.error ? { nativeError: result.error } : {}), + }; +} + +export async function setupWorkerPool(opts: BuildOptions | undefined): Promise { + const shouldUseWorkers = + !!opts?.useNativeWorkers && opts?.native !== "off" && isNativeTreeSitterAvailable(opts?.native); + const report: WorkerPoolReport | undefined = opts?.useNativeWorkers + ? { + enabled: shouldUseWorkers, + threads: 0, + tasksSubmitted: 0, + tasksFailed: 0, + } + : undefined; + let pool: import("piscina").Piscina | null = null; + if (shouldUseWorkers) { + try { + const { createNativeWorkerPool } = await import("../worker/nativeWorkerPool.js"); + const createdPool = createNativeWorkerPool({ + threads: opts.nativeThreads, + }); + pool = createdPool; + if (report) { + report.threads = (createdPool.options as { maxThreads?: number }).maxThreads ?? 0; + } + } catch (error) { + pool = null; + if (report) { + report.enabled = false; + report.startupError = stringifyUnknown(error); + } + } + } + return { pool, report, startTime: pool ? performance.now() : 0 }; +} + +export async function teardownWorkerPool( + setup: WorkerPoolSetupResult, + buildReport: BuildReport | undefined, +): Promise { + if (setup.pool) { + if (setup.report) { + setup.report.wallClockMs = Math.round(performance.now() - setup.startTime); + } + try { + await setup.pool.destroy(); + } catch { + // non-fatal + } + setup.pool = null; + } + if (buildReport && setup.report) { + buildReport.workerPool = setup.report; + } +} + +export async function prepareFileContextForBuild( + file: string, + support: LanguageSupport, + opts: BuildOptions | undefined, + workerSetup: WorkerPoolSetupResult, + report: BuildReport | undefined, +): Promise { + let prepared: PreparedFileContext; + if (workerSetup.pool && !isSFCFile(file)) { + if (workerSetup.report) workerSetup.report.tasksSubmitted++; + try { + const workerResult: NativeExtractResult = await workerSetup.pool.run(buildWorkerTask(file, support)); + prepared = workerResultToPrepared(workerResult, support, file); + } catch (error) { + if (isNativeRequiredUnavailableError(error)) throw error; + if (workerSetup.report) workerSetup.report.tasksFailed++; + if (workerSetup.report) { + workerSetup.report.errors ??= []; + if (workerSetup.report.errors.length < 20) { + workerSetup.report.errors.push({ + file, + message: stringifyUnknown(error), + }); + } + } + prepared = await prepareFileForIndexing(file, opts?.native); + } + } else { + prepared = await prepareFileForIndexing(file, opts?.native); + } + recordNativeExecutionOutcome(report, { + file, + support: prepared.sup, + languageId: prepared.sup.id, + results: prepared.nativeQueries, + ...(prepared.nativeFallbackReason ? { fallbackReason: prepared.nativeFallbackReason } : {}), + ...(prepared.nativeError ? { error: prepared.nativeError } : {}), + }); + return prepared; +} diff --git a/src/indexer/finalize.ts b/src/indexer/finalize.ts new file mode 100644 index 00000000..5e59b37e --- /dev/null +++ b/src/indexer/finalize.ts @@ -0,0 +1,39 @@ +import { performance } from "node:perf_hooks"; +import { buildGraphAdjacency } from "../graphs/adjacency.js"; +import { discoverProjectFiles } from "../util/projectFiles.js"; +import type { FileId, Graph } from "../types.js"; +import type { BloomFilterCache } from "../util/bloomFilter.js"; +import type { ParsedFileContext } from "./parse-context.js"; +import { retainedParsedCache } from "./parsed-cache.js"; +import type { BuildOptions, BuildReport, ModuleIndex, ProjectIndex } from "./types.js"; + +export async function finalizeProjectIndex(args: { + projectRoot: string; + normalizedProjectRoot: string; + opts: BuildOptions | undefined; + timings: BuildReport["timings"] | undefined; + totalStart: number; + graph: Graph; + modules: Map; + parsedMap: Map; + bloomFilterCache: BloomFilterCache | undefined; +}): Promise { + if (args.timings) args.timings.totalMs = Math.round(performance.now() - args.totalStart); + const projectFiles = await discoverProjectFiles(args.projectRoot, { + ...(args.opts?.logLevel ? { logLevel: args.opts.logLevel } : {}), + }); + const parsed = retainedParsedCache(args.parsedMap, args.opts); + return { + graph: args.graph, + graphAdjacency: buildGraphAdjacency(args.graph), + modules: args.modules, + byFile: args.modules, + projectRoot: args.normalizedProjectRoot, + ...(args.opts?.native ? { nativeMode: args.opts.native } : {}), + exportCache: new Map(), + scopeCache: new Map(), + ...(parsed ? { parsed } : {}), + ...(args.bloomFilterCache ? { bloomFilters: args.bloomFilterCache } : {}), + projectFiles, + }; +} diff --git a/src/indexer/incremental-plan.ts b/src/indexer/incremental-plan.ts new file mode 100644 index 00000000..2479c8c9 --- /dev/null +++ b/src/indexer/incremental-plan.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import type { ManifestFileEntry } from "./build-cache.js"; +import type { IncrementalBuildOptions } from "./types.js"; + +export type IncrementalGitDiffOptions = { + base?: string; + head?: string; + changedSince?: string; +}; + +export type TrackedManifestFilePlan = { + trackedFileList: string[]; + trackedFiles: Set; + deletedTrackedFiles: Set; +}; + +export function isMissingGitRevisionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("Invalid revision range") || + message.includes("bad revision") || + message.includes("unknown revision") || + message.includes("ambiguous argument") + ); +} + +export function buildIncrementalGitDiffOptions(opts: IncrementalBuildOptions | undefined): IncrementalGitDiffOptions { + const gitOpts: IncrementalGitDiffOptions = {}; + if (opts?.gitBase) gitOpts.base = opts.gitBase; + if (opts?.gitHead) gitOpts.head = opts.gitHead; + if (!opts?.gitBase && opts?.changedSince) gitOpts.changedSince = opts.changedSince; + return gitOpts; +} + +export function partitionTrackedManifestFiles( + trackedEntries: Record, +): TrackedManifestFilePlan { + const trackedFileList = Object.keys(trackedEntries); + return { + trackedFileList, + trackedFiles: new Set(trackedFileList.filter((file) => fs.existsSync(file))), + deletedTrackedFiles: new Set(trackedFileList.filter((file) => !fs.existsSync(file))), + }; +} + +export function collectDeletedTrackedFileDependents( + trackedEntries: Record, + deletedTrackedFiles: ReadonlySet, +): Set { + const dependents = new Set(); + if (!deletedTrackedFiles.size) return dependents; + for (const [file, entry] of Object.entries(trackedEntries)) { + if (deletedTrackedFiles.has(file)) continue; + if (entry.edges.some((edge) => edge.to.type === "file" && deletedTrackedFiles.has(edge.to.path))) { + dependents.add(file); + } + } + return dependents; +} diff --git a/src/indexer/parsed-cache.ts b/src/indexer/parsed-cache.ts new file mode 100644 index 00000000..632ab645 --- /dev/null +++ b/src/indexer/parsed-cache.ts @@ -0,0 +1,39 @@ +import type { ParsedFileContext } from "./parse-context.js"; +import type { BuildOptions } from "./types.js"; + +export function parsedCacheMaxEntries(opts: BuildOptions | undefined): number { + return Math.max(1, opts?.parsedCacheMaxEntries ?? 1024); +} + +export function setParsedCacheEntry( + parsedMap: Map, + file: string, + entry: ParsedFileContext, + maxEntries: number, +): void { + if (parsedMap.has(file)) parsedMap.delete(file); + parsedMap.set(file, entry); + while (parsedMap.size > maxEntries) { + const oldest = parsedMap.keys().next().value; + if (!oldest) break; + parsedMap.delete(oldest); + } +} + +export function retainedParsedCache( + parsedMap: Map, + opts: BuildOptions | undefined, +): Map | undefined { + const keepParsed = opts?.keepParsed ?? false; + const maxParsedEntries = parsedCacheMaxEntries(opts); + if (!keepParsed) { + parsedMap.clear(); + return undefined; + } + while (parsedMap.size > maxParsedEntries) { + const oldest = parsedMap.keys().next().value; + if (!oldest) break; + parsedMap.delete(oldest); + } + return parsedMap; +} diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 38f921ee..9db3a67a 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -521,15 +521,23 @@ describe("Cache invalidation and strict hashing", () => { const aEntryBefore = manifestBefore.files[normalize(aPath)]; const prepSpy = vi.spyOn(filePrep, "prepareSourceInput"); + const report: BuildReport = { timings: {} }; const incremental = await buildProjectIndexIncremental(root, { threads: 2, cache: "disk", + report, }); expect(prepSpy).not.toHaveBeenCalled(); prepSpy.mockRestore(); const aEdges = incremental.graph.edges.filter((edge) => edge.from === normalize(aPath)); expect(aEdges).toEqual(aEntryBefore.edges); + expect(report.files?.total).toBe(2); + expect(report.files?.cached).toBe(2); + expect(report.timings?.manifestMs).toEqual(expect.any(Number)); + expect(report.timings?.graphMs).toEqual(expect.any(Number)); + expect(report.timings?.writeManifestMs).toEqual(expect.any(Number)); + expect(report.timings?.totalMs).toEqual(expect.any(Number)); }); it("drops manifest edges for deleted files during incremental builds", async () => { From edce7e46620b8d980b8da8d2de28b84deb7b1e51 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 19:04:37 -0400 Subject: [PATCH 17/25] Split PHP Composer resolution --- REVIEW_ANALYSIS_NEXT.md | 2 +- src/util/resolution/php.ts | 248 +---------------- src/util/resolution/phpComposer.ts | 257 ++++++++++++++++++ tests/goto.test.ts | 64 +++++ tests/languages/php.test.ts | 167 ++++++++++++ tests/references.test.ts | 64 +++++ tests/samples/php/autoload/dev_helper.php | 6 + tests/samples/php/autoload/global_helper.php | 6 + .../samples/php/classmap/Excluded/Hidden.php | 7 + tests/samples/php/classmap/Specific.php | 7 + .../php/composer-classmap-consumer.php | 5 + .../php/composer-dev-classmap-consumer.php | 5 + .../php/composer-dev-psr0-consumer.php | 5 + .../php/composer-dev-psr4-consumer.php | 5 + .../composer-excluded-classmap-consumer.php | 5 + tests/samples/php/composer-files-consumer.php | 4 + tests/samples/php/composer-psr0-consumer.php | 5 + tests/samples/php/composer.json | 26 +- .../samples/php/dev-classmap/DevSpecific.php | 7 + tests/samples/php/dev-legacy/Tools/Box.php | 7 + tests/samples/php/dev-src/Tool.php | 7 + tests/samples/php/legacy/Tools/Box.php | 7 + 22 files changed, 677 insertions(+), 239 deletions(-) create mode 100644 src/util/resolution/phpComposer.ts create mode 100644 tests/samples/php/autoload/dev_helper.php create mode 100644 tests/samples/php/autoload/global_helper.php create mode 100644 tests/samples/php/classmap/Excluded/Hidden.php create mode 100644 tests/samples/php/classmap/Specific.php create mode 100644 tests/samples/php/composer-classmap-consumer.php create mode 100644 tests/samples/php/composer-dev-classmap-consumer.php create mode 100644 tests/samples/php/composer-dev-psr0-consumer.php create mode 100644 tests/samples/php/composer-dev-psr4-consumer.php create mode 100644 tests/samples/php/composer-excluded-classmap-consumer.php create mode 100644 tests/samples/php/composer-files-consumer.php create mode 100644 tests/samples/php/composer-psr0-consumer.php create mode 100644 tests/samples/php/dev-classmap/DevSpecific.php create mode 100644 tests/samples/php/dev-legacy/Tools/Box.php create mode 100644 tests/samples/php/dev-src/Tool.php create mode 100644 tests/samples/php/legacy/Tools/Box.php diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 3863dd21..043ec12d 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -129,7 +129,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - incremental changed-file planning - Add focused tests around cache strictness, changed/deleted files, worker usage, and report timings. -- [ ] Improve PHP resolution maintainability and coverage. +- [x] Improve PHP resolution maintainability and coverage. - Finding: PHP Composer parsing, token scanning, package symbol indexing, PSR-0/PSR-4 resolution, classmap exclusion, and implicit files are concentrated in `src/util/resolution.ts`. - Target: split into a PHP resolver module with fixtures for Composer `autoload`, `autoload-dev`, `files`, PSR-0, PSR-4, classmap, and `exclude-from-classmap`. - Add tests in `tests/languages/php.test.ts`, `tests/goto.test.ts`, and `tests/references.test.ts` for each Composer branch. diff --git a/src/util/resolution/php.ts b/src/util/resolution/php.ts index 67a3d85f..83bbf3c2 100644 --- a/src/util/resolution/php.ts +++ b/src/util/resolution/php.ts @@ -1,12 +1,19 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { stringifyUnknown } from "../ast.js"; -import { normalizePath } from "../paths.js"; -import { listProjectFiles } from "../projectFiles.js"; import { listResolutionCandidates } from "../resolutionCandidates.js"; import { mapLimitSemaphore } from "../concurrency.js"; import { fileExists } from "../workspace.js"; -import { findNearestFile } from "./files.js"; +import { + clearPhpComposerResolutionCaches, + findPhpComposerPath, + getPhpComposerAutoloadFiles, + isPhpComposerClassmapExcluded, + loadPhpComposerConfig, + resolvePhpPsr0MappedPath, + resolvePhpPsr4MappedPath, +} from "./phpComposer.js"; +export { getPhpComposerImplicitFiles } from "./phpComposer.js"; import { addProjectSymbolFile, getOrCreateProjectSymbolIndex, @@ -69,19 +76,9 @@ type PhpSymbolIndexEntry = { packageEntries: PhpPackageSymbolIndexEntry[]; }; -type PhpComposerConfig = { - psr4: Map; - psr0: Map; - classmap: string[]; - excludeFromClassmap: string[]; - files: string[]; -}; - const phpImportResolutionCache = new Map(); const phpSymbolIndexCache = new Map(); const phpProjectSymbolIndexCache = new Map>(); -const phpComposerConfigCache = new Map>(); -const phpComposerAutoloadFileCache = new Map>>(); async function getPhpProjectSymbolIndex(projectRoot: string): Promise { return await getOrCreateProjectSymbolIndex(phpProjectSymbolIndexCache, projectRoot, async () => { @@ -473,147 +470,6 @@ function tokenizePhpSource(source: string): PhpScannerToken[] { return tokens; } -function readComposerNamespaceDirs(value: unknown, composerDir: string): Map { - const result = new Map(); - if (!value || typeof value !== "object") { - return result; - } - for (const [prefix, rawTarget] of Object.entries(value as Record)) { - const targets = Array.isArray(rawTarget) ? rawTarget : [rawTarget]; - const dirs = targets - .filter((target): target is string => typeof target === "string") - .map((target) => resolveComposerPath(target, composerDir)); - if (dirs.length) { - result.set(prefix, dirs); - } - } - return result; -} - -function mergeComposerNamespaceDirMaps(...maps: Map[]): Map { - const merged = new Map(); - for (const map of maps) { - for (const [prefix, dirs] of map) { - const currentDirs = merged.get(prefix) ?? []; - const dedupedDirs = Array.from(new Set([...currentDirs, ...dirs])); - merged.set(prefix, dedupedDirs); - } - } - return merged; -} - -function resolveComposerPath(entry: string, composerDir: string): string { - if (entry.startsWith("/") || entry.startsWith("\\")) { - return path.resolve(composerDir, `.${entry}`); - } - if (/^[A-Za-z]:[\\/]/.test(entry) || path.isAbsolute(entry)) { - return path.resolve(entry); - } - return path.resolve(composerDir, entry); -} - -function readComposerStringList(value: unknown, composerDir: string): string[] { - if (!Array.isArray(value)) return []; - return value - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => resolveComposerPath(entry, composerDir)); -} - -async function loadPhpComposerConfig(composerPath: string): Promise { - const cached = phpComposerConfigCache.get(composerPath); - if (cached) return await cached; - - const pending = (async () => { - try { - const raw = await fsp.readFile(composerPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - const composerDir = path.dirname(composerPath); - const autoload = - parsed.autoload && typeof parsed.autoload === "object" ? (parsed.autoload as Record) : {}; - const autoloadDev = - parsed["autoload-dev"] && typeof parsed["autoload-dev"] === "object" - ? (parsed["autoload-dev"] as Record) - : {}; - - const psr4 = mergeComposerNamespaceDirMaps( - readComposerNamespaceDirs(autoload["psr-4"], composerDir), - readComposerNamespaceDirs(autoloadDev["psr-4"], composerDir), - ); - const psr0 = mergeComposerNamespaceDirMaps( - readComposerNamespaceDirs(autoload["psr-0"], composerDir), - readComposerNamespaceDirs(autoloadDev["psr-0"], composerDir), - ); - const classmap = [ - ...readComposerStringList(autoload["classmap"], composerDir), - ...readComposerStringList(autoloadDev["classmap"], composerDir), - ]; - const excludeFromClassmap = [ - ...readComposerStringList(autoload["exclude-from-classmap"], composerDir), - ...readComposerStringList(autoloadDev["exclude-from-classmap"], composerDir), - ]; - const files = [ - ...readComposerStringList(autoload["files"], composerDir), - ...readComposerStringList(autoloadDev["files"], composerDir), - ]; - - return { psr4, psr0, classmap, excludeFromClassmap, files }; - } catch { - return null; - } - })(); - - phpComposerConfigCache.set(composerPath, pending); - return await pending; -} - -function sortPhpComposerMappings(mappings: Map): Array<[string, string[]]> { - return Array.from(mappings.entries()).sort((left, right) => right[0].length - left[0].length); -} - -async function resolvePhpPsr4MappedPath(spec: string, mappings: Map): Promise { - const normalizedSpec = spec.replace(/^\\+/, ""); - const mappingEntries = sortPhpComposerMappings(mappings); - - for (const [prefix, dirs] of mappingEntries) { - if (!normalizedSpec.startsWith(prefix)) continue; - const suffix = normalizedSpec.slice(prefix.length).replace(/\\/g, "/"); - for (const dir of dirs) { - const basePath = suffix ? path.join(dir, suffix) : dir; - const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); - if (resolved) return resolved; - } - } - - return null; -} - -function buildPhpPsr0RelativePath(spec: string, prefix: string): string | null { - if (!spec.startsWith(prefix)) return null; - const suffix = spec.slice(prefix.length); - const namespaceParts = suffix.split("\\"); - const classPart = namespaceParts.pop() ?? ""; - const namespacePath = namespaceParts.filter(Boolean).join("/"); - const classPath = classPart.replace(/_/g, "/"); - return [namespacePath, classPath].filter(Boolean).join("/"); -} - -async function resolvePhpPsr0MappedPath(spec: string, mappings: Map): Promise { - const normalizedSpec = spec.replace(/^\\+/, ""); - const mappingEntries = sortPhpComposerMappings(mappings); - - for (const [prefix, dirs] of mappingEntries) { - const relativePath = buildPhpPsr0RelativePath(normalizedSpec, prefix); - if (relativePath === null) continue; - for (const dir of dirs) { - const basePath = relativePath ? path.join(dir, relativePath) : dir; - const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); - if (resolved) return resolved; - } - } - - return null; -} - async function resolvePhpSymbolImportPath( projectRoot: string, spec: string, @@ -668,87 +524,6 @@ async function resolvePhpSymbolImportPath( return await pickCandidate(packageFiles, importedName); } -async function findPhpComposerPath(projectRoot: string, fromFile: string): Promise { - return ( - (await findNearestFile(path.dirname(fromFile), projectRoot, "composer.json")) ?? - ((await fileExists(path.join(projectRoot, "composer.json"))) ? path.join(projectRoot, "composer.json") : null) - ); -} - -export async function getPhpComposerImplicitFiles(projectRoot: string, fromFile: string): Promise { - const composerPath = await findPhpComposerPath(projectRoot, fromFile); - if (!composerPath) { - return []; - } - - const composerConfig = await loadPhpComposerConfig(composerPath); - if (!composerConfig) { - return []; - } - - const deduped = new Set(); - for (const filePath of composerConfig.files) { - if (!(await fileExists(filePath))) continue; - deduped.add(path.resolve(filePath)); - } - return Array.from(deduped); -} - -async function getPhpComposerAutoloadFiles( - composerPath: string, - composerConfig: PhpComposerConfig, -): Promise> { - const cached = phpComposerAutoloadFileCache.get(composerPath); - if (cached) { - return await cached; - } - - const pending = (async () => { - const candidates = new Set(); - const roots = new Set([ - ...composerConfig.classmap, - ...composerConfig.files, - ...Array.from(composerConfig.psr4.values()).flat(), - ...Array.from(composerConfig.psr0.values()).flat(), - ]); - - for (const root of roots) { - try { - const stat = await fsp.stat(root); - if (stat.isDirectory()) { - const files = await listProjectFiles(root, ["**/*.php"]); - for (const filePath of files) { - if (isPhpComposerClassmapExcluded(filePath, composerConfig)) { - continue; - } - candidates.add(path.resolve(filePath)); - } - continue; - } - if (stat.isFile() && root.toLowerCase().endsWith(".php")) { - if (isPhpComposerClassmapExcluded(root, composerConfig)) continue; - candidates.add(path.resolve(root)); - } - } catch { - // Ignore missing Composer autoload roots. - } - } - - return candidates; - })(); - - phpComposerAutoloadFileCache.set(composerPath, pending); - return await pending; -} - -function isPhpComposerClassmapExcluded(filePath: string, composerConfig: PhpComposerConfig): boolean { - const normalizedFile = normalizePath(path.resolve(filePath)); - return composerConfig.excludeFromClassmap.some((entry) => { - const normalizedEntry = normalizePath(path.resolve(entry)).replace(/\/+$/, ""); - return normalizedFile === normalizedEntry || normalizedFile.startsWith(`${normalizedEntry}/`); - }); -} - export async function resolvePhpImportPath( projectRoot: string, fromFile: string, @@ -817,6 +592,5 @@ export function clearPhpResolutionCaches(): void { phpImportResolutionCache.clear(); phpSymbolIndexCache.clear(); phpProjectSymbolIndexCache.clear(); - phpComposerConfigCache.clear(); - phpComposerAutoloadFileCache.clear(); + clearPhpComposerResolutionCaches(); } diff --git a/src/util/resolution/phpComposer.ts b/src/util/resolution/phpComposer.ts new file mode 100644 index 00000000..ea52aff3 --- /dev/null +++ b/src/util/resolution/phpComposer.ts @@ -0,0 +1,257 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { normalizePath } from "../paths.js"; +import { listProjectFiles } from "../projectFiles.js"; +import { listResolutionCandidates } from "../resolutionCandidates.js"; +import { fileExists } from "../workspace.js"; +import { findNearestFile } from "./files.js"; + +export type PhpComposerConfig = { + psr4: Map; + psr0: Map; + classmap: string[]; + excludeFromClassmap: string[]; + files: string[]; +}; + +const phpComposerConfigCache = new Map>(); +const phpComposerAutoloadFileCache = new Map>>(); + +async function findFirstExistingResolutionCandidate( + base: string, + resolutionExtensions?: readonly string[], +): Promise { + for (const candidate of listResolutionCandidates(base, resolutionExtensions)) { + if (await fileExists(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +function readComposerNamespaceDirs(value: unknown, composerDir: string): Map { + const result = new Map(); + if (!value || typeof value !== "object") { + return result; + } + for (const [prefix, rawTarget] of Object.entries(value as Record)) { + const targets = Array.isArray(rawTarget) ? rawTarget : [rawTarget]; + const dirs = targets + .filter((target): target is string => typeof target === "string") + .map((target) => resolveComposerPath(target, composerDir)); + if (dirs.length) { + result.set(prefix, dirs); + } + } + return result; +} + +function mergeComposerNamespaceDirMaps(...maps: Map[]): Map { + const merged = new Map(); + for (const map of maps) { + for (const [prefix, dirs] of map) { + const currentDirs = merged.get(prefix) ?? []; + const dedupedDirs = Array.from(new Set([...currentDirs, ...dirs])); + merged.set(prefix, dedupedDirs); + } + } + return merged; +} + +function resolveComposerPath(entry: string, composerDir: string): string { + if (entry.startsWith("/") || entry.startsWith("\\")) { + return path.resolve(composerDir, `.${entry}`); + } + if (/^[A-Za-z]:[\\/]/.test(entry) || path.isAbsolute(entry)) { + return path.resolve(entry); + } + return path.resolve(composerDir, entry); +} + +function readComposerStringList(value: unknown, composerDir: string): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => resolveComposerPath(entry, composerDir)); +} + +export async function loadPhpComposerConfig(composerPath: string): Promise { + const cached = phpComposerConfigCache.get(composerPath); + if (cached) return await cached; + + const pending = (async () => { + try { + const raw = await fsp.readFile(composerPath, "utf8"); + const parsed = JSON.parse(raw) as Record; + const composerDir = path.dirname(composerPath); + const autoload = + parsed.autoload && typeof parsed.autoload === "object" ? (parsed.autoload as Record) : {}; + const autoloadDev = + parsed["autoload-dev"] && typeof parsed["autoload-dev"] === "object" + ? (parsed["autoload-dev"] as Record) + : {}; + + const psr4 = mergeComposerNamespaceDirMaps( + readComposerNamespaceDirs(autoload["psr-4"], composerDir), + readComposerNamespaceDirs(autoloadDev["psr-4"], composerDir), + ); + const psr0 = mergeComposerNamespaceDirMaps( + readComposerNamespaceDirs(autoload["psr-0"], composerDir), + readComposerNamespaceDirs(autoloadDev["psr-0"], composerDir), + ); + const classmap = [ + ...readComposerStringList(autoload["classmap"], composerDir), + ...readComposerStringList(autoloadDev["classmap"], composerDir), + ]; + const excludeFromClassmap = [ + ...readComposerStringList(autoload["exclude-from-classmap"], composerDir), + ...readComposerStringList(autoloadDev["exclude-from-classmap"], composerDir), + ]; + const files = [ + ...readComposerStringList(autoload["files"], composerDir), + ...readComposerStringList(autoloadDev["files"], composerDir), + ]; + + return { psr4, psr0, classmap, excludeFromClassmap, files }; + } catch { + return null; + } + })(); + + phpComposerConfigCache.set(composerPath, pending); + return await pending; +} + +function sortPhpComposerMappings(mappings: Map): Array<[string, string[]]> { + return Array.from(mappings.entries()).sort((left, right) => right[0].length - left[0].length); +} + +export async function resolvePhpPsr4MappedPath(spec: string, mappings: Map): Promise { + const normalizedSpec = spec.replace(/^\\+/, ""); + const mappingEntries = sortPhpComposerMappings(mappings); + + for (const [prefix, dirs] of mappingEntries) { + if (!normalizedSpec.startsWith(prefix)) continue; + const suffix = normalizedSpec.slice(prefix.length).replace(/\\/g, "/"); + for (const dir of dirs) { + const basePath = suffix ? path.join(dir, suffix) : dir; + const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); + if (resolved) return resolved; + } + } + + return null; +} + +function buildPhpPsr0RelativePath(spec: string, prefix: string): string | null { + if (!spec.startsWith(prefix)) return null; + const suffix = spec.slice(prefix.length); + const namespaceParts = suffix.split("\\"); + const classPart = namespaceParts.pop() ?? ""; + const namespacePath = namespaceParts.filter(Boolean).join("/"); + const classPath = classPart.replace(/_/g, "/"); + return [namespacePath, classPath].filter(Boolean).join("/"); +} + +export async function resolvePhpPsr0MappedPath(spec: string, mappings: Map): Promise { + const normalizedSpec = spec.replace(/^\\+/, ""); + const mappingEntries = sortPhpComposerMappings(mappings); + + for (const [prefix, dirs] of mappingEntries) { + const relativePath = buildPhpPsr0RelativePath(normalizedSpec, prefix); + if (relativePath === null) continue; + for (const dir of dirs) { + const basePath = relativePath ? path.join(dir, relativePath) : dir; + const resolved = await findFirstExistingResolutionCandidate(basePath, [".php"]); + if (resolved) return resolved; + } + } + + return null; +} + +export async function findPhpComposerPath(projectRoot: string, fromFile: string): Promise { + return ( + (await findNearestFile(path.dirname(fromFile), projectRoot, "composer.json")) ?? + ((await fileExists(path.join(projectRoot, "composer.json"))) ? path.join(projectRoot, "composer.json") : null) + ); +} + +export async function getPhpComposerImplicitFiles(projectRoot: string, fromFile: string): Promise { + const composerPath = await findPhpComposerPath(projectRoot, fromFile); + if (!composerPath) { + return []; + } + + const composerConfig = await loadPhpComposerConfig(composerPath); + if (!composerConfig) { + return []; + } + + const deduped = new Set(); + for (const filePath of composerConfig.files) { + if (!(await fileExists(filePath))) continue; + deduped.add(path.resolve(filePath)); + } + return Array.from(deduped); +} + +export async function getPhpComposerAutoloadFiles( + composerPath: string, + composerConfig: PhpComposerConfig, +): Promise> { + const cached = phpComposerAutoloadFileCache.get(composerPath); + if (cached) { + return await cached; + } + + const pending = (async () => { + const candidates = new Set(); + const roots = new Set([ + ...composerConfig.classmap, + ...composerConfig.files, + ...Array.from(composerConfig.psr4.values()).flat(), + ...Array.from(composerConfig.psr0.values()).flat(), + ]); + + for (const root of roots) { + try { + const stat = await fsp.stat(root); + if (stat.isDirectory()) { + const files = await listProjectFiles(root, ["**/*.php"]); + for (const filePath of files) { + if (isPhpComposerClassmapExcluded(filePath, composerConfig)) { + continue; + } + candidates.add(path.resolve(filePath)); + } + continue; + } + if (stat.isFile() && root.toLowerCase().endsWith(".php")) { + if (isPhpComposerClassmapExcluded(root, composerConfig)) continue; + candidates.add(path.resolve(root)); + } + } catch { + // Ignore missing Composer autoload roots. + } + } + + return candidates; + })(); + + phpComposerAutoloadFileCache.set(composerPath, pending); + return await pending; +} + +export function isPhpComposerClassmapExcluded(filePath: string, composerConfig: PhpComposerConfig): boolean { + const normalizedFile = normalizePath(path.resolve(filePath)); + return composerConfig.excludeFromClassmap.some((entry) => { + const normalizedEntry = normalizePath(path.resolve(entry)).replace(/\/+$/, ""); + return normalizedFile === normalizedEntry || normalizedFile.startsWith(`${normalizedEntry}/`); + }); +} + +export function clearPhpComposerResolutionCaches(): void { + phpComposerConfigCache.clear(); + phpComposerAutoloadFileCache.clear(); +} diff --git a/tests/goto.test.ts b/tests/goto.test.ts index 6d2fb730..a38487ed 100644 --- a/tests/goto.test.ts +++ b/tests/goto.test.ts @@ -566,6 +566,70 @@ describe("Go to Definition", () => { await testGoToDefinition(index, consumerFile, 3, 37, serviceFile, 5); }); + it("should find definitions through Composer PSR-0, autoload-dev, classmap, and files entries", async () => { + const index = await createTestIndex("php"); + const samplePath = path.resolve(process.cwd(), "tests", "samples", "php"); + + await testGoToDefinition( + index, + path.join(samplePath, "composer-psr0-consumer.php").replace(/\\/g, "/"), + 5, + 6, + path.join(samplePath, "legacy", "Tools", "Box.php").replace(/\\/g, "/"), + 5, + ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-dev-psr4-consumer.php").replace(/\\/g, "/"), + 5, + 6, + path.join(samplePath, "dev-src", "Tool.php").replace(/\\/g, "/"), + 5, + ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-dev-psr0-consumer.php").replace(/\\/g, "/"), + 5, + 6, + path.join(samplePath, "dev-legacy", "Tools", "Box.php").replace(/\\/g, "/"), + 5, + ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-classmap-consumer.php").replace(/\\/g, "/"), + 5, + 6, + path.join(samplePath, "classmap", "Specific.php").replace(/\\/g, "/"), + 5, + ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-dev-classmap-consumer.php").replace(/\\/g, "/"), + 5, + 6, + path.join(samplePath, "dev-classmap", "DevSpecific.php").replace(/\\/g, "/"), + 5, + ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-files-consumer.php").replace(/\\/g, "/"), + 3, + 3, + path.join(samplePath, "autoload", "global_helper.php").replace(/\\/g, "/"), + 3, + ); + }); + + it("should not resolve Composer classes excluded from classmap", async () => { + const index = await createTestIndex("php"); + const samplePath = path.resolve(process.cwd(), "tests", "samples", "php"); + const consumerFile = path.join(samplePath, "composer-excluded-classmap-consumer.php").replace(/\\/g, "/"); + + const result = await testGoToDefinition(index, consumerFile, 5, 6, undefined, undefined, "not_found"); + + expect(result.status).toBe("not_found"); + }); + it("should respect PHP function import kinds when class names collide", async () => { const index = await createTestIndex("php"); const samplePath = path.resolve(process.cwd(), "tests", "samples", "php"); diff --git a/tests/languages/php.test.ts b/tests/languages/php.test.ts index e59fd51c..e1d473ed 100644 --- a/tests/languages/php.test.ts +++ b/tests/languages/php.test.ts @@ -61,6 +61,34 @@ const definition: LanguageTestDefinition = { from: "composer-type-qualified-consumer.php", to: { type: "file", path: "src/Domain/Service.php" }, }, + { + from: "composer-psr0-consumer.php", + to: { type: "file", path: "legacy/Tools/Box.php" }, + }, + { + from: "composer-dev-psr4-consumer.php", + to: { type: "file", path: "dev-src/Tool.php" }, + }, + { + from: "composer-dev-psr0-consumer.php", + to: { type: "file", path: "dev-legacy/Tools/Box.php" }, + }, + { + from: "composer-classmap-consumer.php", + to: { type: "file", path: "classmap/Specific.php" }, + }, + { + from: "composer-dev-classmap-consumer.php", + to: { type: "file", path: "dev-classmap/DevSpecific.php" }, + }, + { + from: "composer-files-consumer.php", + to: { type: "file", path: "autoload/global_helper.php" }, + }, + { + from: "composer-files-consumer.php", + to: { type: "file", path: "autoload/dev_helper.php" }, + }, { from: "function-import-consumer.php", to: { type: "file", path: "src/Collision/ThingFunction.php" }, @@ -74,6 +102,12 @@ const definition: LanguageTestDefinition = { to: { type: "file", path: "multi-namespace/Library.php" }, }, ], + absentDependencyGraph: [ + { + from: "composer-excluded-classmap-consumer.php", + to: { type: "file", path: "classmap/Excluded/Hidden.php" }, + }, + ], symbols: [ { file: "utils.php", @@ -103,6 +137,34 @@ const definition: LanguageTestDefinition = { file: "src/Collision/ThingFunction.php", includes: [{ name: "Thing" }], }, + { + file: "legacy/Tools/Box.php", + includes: [{ name: "Tools_Box" }], + }, + { + file: "dev-src/Tool.php", + includes: [{ name: "Tool" }], + }, + { + file: "dev-legacy/Tools/Box.php", + includes: [{ name: "Tools_Box" }], + }, + { + file: "classmap/Specific.php", + includes: [{ name: "Specific" }], + }, + { + file: "dev-classmap/DevSpecific.php", + includes: [{ name: "DevSpecific" }], + }, + { + file: "autoload/global_helper.php", + includes: [{ name: "global_helper" }], + }, + { + file: "autoload/dev_helper.php", + includes: [{ name: "dev_helper" }], + }, { file: "multi-namespace/Library.php", includes: [{ name: "FirstService" }, { name: "SecondService" }], @@ -179,6 +241,62 @@ const definition: LanguageTestDefinition = { column: 37, expectedDefinition: { file: "src/Domain/Service.php", line: 5 }, }, + { + name: "go to definition resolves Composer PSR-0 classes", + file: "composer-psr0-consumer.php", + line: 5, + column: 6, + expectedDefinition: { file: "legacy/Tools/Box.php", line: 5 }, + }, + { + name: "go to definition resolves Composer autoload-dev PSR-4 classes", + file: "composer-dev-psr4-consumer.php", + line: 5, + column: 6, + expectedDefinition: { file: "dev-src/Tool.php", line: 5 }, + }, + { + name: "go to definition resolves Composer autoload-dev PSR-0 classes", + file: "composer-dev-psr0-consumer.php", + line: 5, + column: 6, + expectedDefinition: { file: "dev-legacy/Tools/Box.php", line: 5 }, + }, + { + name: "go to definition resolves Composer classmap classes", + file: "composer-classmap-consumer.php", + line: 5, + column: 6, + expectedDefinition: { file: "classmap/Specific.php", line: 5 }, + }, + { + name: "go to definition resolves Composer autoload-dev classmap classes", + file: "composer-dev-classmap-consumer.php", + line: 5, + column: 6, + expectedDefinition: { file: "dev-classmap/DevSpecific.php", line: 5 }, + }, + { + name: "go to definition skips Composer excluded classmap classes", + file: "composer-excluded-classmap-consumer.php", + line: 5, + column: 6, + expectedStatus: "not_found", + }, + { + name: "go to definition resolves Composer files autoload functions", + file: "composer-files-consumer.php", + line: 3, + column: 3, + expectedDefinition: { file: "autoload/global_helper.php", line: 3 }, + }, + { + name: "go to definition resolves Composer autoload-dev files functions", + file: "composer-files-consumer.php", + line: 4, + column: 3, + expectedDefinition: { file: "autoload/dev_helper.php", line: 3 }, + }, { name: "go to definition respects PHP function import kind", file: "function-import-consumer.php", @@ -237,6 +355,55 @@ const definition: LanguageTestDefinition = { column: 7, minimumCount: 7, }, + { + name: "find references for Composer PSR-0 classes", + file: "legacy/Tools/Box.php", + line: 5, + column: 7, + minimumCount: 2, + }, + { + name: "find references for Composer autoload-dev PSR-4 classes", + file: "dev-src/Tool.php", + line: 5, + column: 7, + minimumCount: 2, + }, + { + name: "find references for Composer autoload-dev PSR-0 classes", + file: "dev-legacy/Tools/Box.php", + line: 5, + column: 7, + minimumCount: 2, + }, + { + name: "find references for Composer classmap classes", + file: "classmap/Specific.php", + line: 5, + column: 7, + minimumCount: 2, + }, + { + name: "find references for Composer autoload-dev classmap classes", + file: "dev-classmap/DevSpecific.php", + line: 5, + column: 7, + minimumCount: 2, + }, + { + name: "find references for Composer files autoload functions", + file: "autoload/global_helper.php", + line: 3, + column: 10, + minimumCount: 2, + }, + { + name: "find references for Composer autoload-dev files functions", + file: "autoload/dev_helper.php", + line: 3, + column: 10, + minimumCount: 2, + }, { name: "find references for PHP classes from bracketed namespace blocks", file: "multi-namespace/Library.php", diff --git a/tests/references.test.ts b/tests/references.test.ts index 51b2894b..1fd2af14 100644 --- a/tests/references.test.ts +++ b/tests/references.test.ts @@ -574,6 +574,70 @@ describe("Find References", () => { } }); + it("should find references for Composer PSR-0, autoload-dev, classmap, and files entries", async () => { + const index = await createTestIndex("php"); + const samplePath = path.resolve(process.cwd(), "tests", "samples", "php"); + const cases = [ + { + definitionFile: path.join(samplePath, "legacy", "Tools", "Box.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 7, + referenceFile: path.join(samplePath, "composer-psr0-consumer.php").replace(/\\/g, "/"), + referenceLine: 5, + }, + { + definitionFile: path.join(samplePath, "dev-src", "Tool.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 7, + referenceFile: path.join(samplePath, "composer-dev-psr4-consumer.php").replace(/\\/g, "/"), + referenceLine: 5, + }, + { + definitionFile: path.join(samplePath, "dev-legacy", "Tools", "Box.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 7, + referenceFile: path.join(samplePath, "composer-dev-psr0-consumer.php").replace(/\\/g, "/"), + referenceLine: 5, + }, + { + definitionFile: path.join(samplePath, "classmap", "Specific.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 7, + referenceFile: path.join(samplePath, "composer-classmap-consumer.php").replace(/\\/g, "/"), + referenceLine: 5, + }, + { + definitionFile: path.join(samplePath, "dev-classmap", "DevSpecific.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 7, + referenceFile: path.join(samplePath, "composer-dev-classmap-consumer.php").replace(/\\/g, "/"), + referenceLine: 5, + }, + { + definitionFile: path.join(samplePath, "autoload", "global_helper.php").replace(/\\/g, "/"), + definitionLine: 3, + definitionColumn: 10, + referenceFile: path.join(samplePath, "composer-files-consumer.php").replace(/\\/g, "/"), + referenceLine: 3, + }, + ]; + + for (const testCase of cases) { + const result = await testFindReferences( + index, + testCase.definitionFile, + testCase.definitionLine, + testCase.definitionColumn, + 2, + ); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expectReferenceAt(result, testCase.definitionFile, testCase.definitionLine); + expectReferenceAt(result, testCase.referenceFile, testCase.referenceLine); + } + } + }); + it("should find references for function imports when class names collide", async () => { const index = await createTestIndex("php"); const samplePath = path.resolve(process.cwd(), "tests", "samples", "php"); diff --git a/tests/samples/php/autoload/dev_helper.php b/tests/samples/php/autoload/dev_helper.php new file mode 100644 index 00000000..dd02cc17 --- /dev/null +++ b/tests/samples/php/autoload/dev_helper.php @@ -0,0 +1,6 @@ + Date: Wed, 20 May 2026 19:22:57 -0400 Subject: [PATCH 18/25] Clarify unresolved import diagnostics --- REVIEW_ANALYSIS_NEXT.md | 2 +- codegraph-skill/codegraph/SKILL.md | 2 +- docs/cli.md | 2 +- docs/language-parity.md | 2 +- docs/library-api.md | 2 + docs/scenario-catalog.md | 2 +- src/graphs/edgeResolution.ts | 4 +- src/graphs/unresolved.ts | 12 ++- src/languages/definitions/javascript.ts | 3 +- src/util/resolution.ts | 6 ++ src/util/resolution/rust.ts | 96 ++++++++++++++++++++++++ src/util/specifiers.ts | 6 +- tests/fallback-import-extraction.test.ts | 5 ++ tests/graph-reports.test.ts | 50 +++++++++++- tests/native-query-scope.test.ts | 17 +++++ 15 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 src/util/resolution/rust.ts diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 043ec12d..1cc24851 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -134,7 +134,7 @@ No dependency cycles were reported. The highest remaining concentration is in: - Target: split into a PHP resolver module with fixtures for Composer `autoload`, `autoload-dev`, `files`, PSR-0, PSR-4, classmap, and `exclude-from-classmap`. - Add tests in `tests/languages/php.test.ts`, `tests/goto.test.ts`, and `tests/references.test.ts` for each Composer branch. -- [ ] Review unresolved-import classification for non-source artifacts. +- [x] Review unresolved-import classification for non-source artifacts. - Finding: whole-repo `unresolved` reports include script command names, `.cursor` plan links, graph-visualization DOM IDs, Rust crate-relative imports, and sample fixture imports. - Target: decide whether `unresolved` should be source-code-only by default, scope-aware by command, or classifier-aware for scripts/docs/test fixtures. - Add docs and tests for intentional unresolved behavior so users can trust the signal. diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 8900fc21..6b30c7b3 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -212,7 +212,7 @@ For git-provider impact and git-scoped review/index/graph commands, `WORKTREE` c `codegraph apisurface` - Unresolved project imports: `codegraph unresolved` - Excludes known runtime/package externals: supported-language standard libraries, URL imports, and dependencies declared in nearby manifests such as `package.json`, Python, PHP, Rust, Go, Zig, Ruby, Java/Kotlin, .NET, C/C++, and Swift package manifests. + Excludes graph-only document/template link edges plus known runtime/package externals: supported-language standard libraries, URL imports, and dependencies declared in nearby manifests such as `package.json`, Python, PHP, Rust, Go, Zig, Ruby, Java/Kotlin, .NET, C/C++, and Swift package manifests. - Hotspots: `codegraph hotspots ./src --limit 20` - Semantic chunking: diff --git a/docs/cli.md b/docs/cli.md index f0d3dd69..6b509bcb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -252,7 +252,7 @@ Impact JSON responses include `schemaVersion` plus `format: "full" | "compact"` SQL review context is emitted only as `sqlContext.entries[]` in structured review JSON. Entries carry a `reason` such as `changed_sql_file` or `changed_sql_literal`, the matched `objectName`, and the original SQL statement fact. They are review hints, not source dependency edges. -`inspect` and `unresolved` exclude known runtime and package externals from unresolved-import counts so diagnostics stay focused on project resolution gaps. This includes Node builtins such as `node:path` and `fs`, supported-language standard library imports, URL imports, and dependencies declared in nearby manifests such as `package.json`, `requirements.txt`, `requirements.in`, `pyproject.toml`, `setup.cfg`, `Pipfile`, `composer.json`, `Cargo.toml`, `go.mod`, `build.zig.zon`, `Gemfile`, `*.gemspec`, `pom.xml`, `build.gradle`, `build.gradle.kts`, `*.csproj`, `*.fsproj`, `*.vbproj`, `vcpkg.json`, and `Package.swift`. +`inspect` and `unresolved` exclude graph-only document/template link edges plus known runtime and package externals from unresolved-import counts so diagnostics stay focused on source import resolution gaps. Runtime and package filtering includes Node builtins such as `node:path` and `fs`, supported-language standard library imports, URL imports, and dependencies declared in nearby manifests such as `package.json`, `requirements.txt`, `requirements.in`, `pyproject.toml`, `setup.cfg`, `Pipfile`, `composer.json`, `Cargo.toml`, `go.mod`, `build.zig.zon`, `Gemfile`, `*.gemspec`, `pom.xml`, `build.gradle`, `build.gradle.kts`, `*.csproj`, `*.fsproj`, `*.vbproj`, `vcpkg.json`, and `Package.swift`. ### Doctor and skill commands diff --git a/docs/language-parity.md b/docs/language-parity.md index 32fe31a1..24becbca 100644 --- a/docs/language-parity.md +++ b/docs/language-parity.md @@ -72,4 +72,4 @@ Status key: - C and C++: `CMakeLists.txt`, `CMakePresets.json`, `CMakeUserPresets.json`, `Makefile`, `makefile`, `GNUmakefile`, `configure.ac`, `configure.in`, `meson.build`, `meson_options.txt`, `conanfile.txt`, `conanfile.py`, `vcpkg.json`. Name extraction: Yes for `vcpkg.json`, Partial for directory fallback cases. - IDE: `.idea`. Name extraction: Partial via directory fallback. -`inspect` and `unresolved` also use supported-language dependency manifests to suppress declared third-party packages from unresolved-import diagnostics. Manifest traversal is bounded to the nearest project manifest boundary so scoped scans stay deterministic and do not inherit unrelated parent directories. +`inspect` and `unresolved` also use supported-language dependency manifests to suppress declared third-party packages from unresolved-import diagnostics. Manifest traversal is bounded to the nearest project manifest boundary so scoped scans stay deterministic and do not inherit unrelated parent directories. Graph-only document and template link edges stay available in graph output, but unresolved-import diagnostics exclude them by default so source import health is not mixed with documentation link checking. diff --git a/docs/library-api.md b/docs/library-api.md index f09cb8bb..9bfdc226 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -296,6 +296,8 @@ for (const edge of graph.edges) { } ``` +`getUnresolvedImports(graph, { projectRoot })` reports unresolved source imports. It excludes graph-only document/template link edges by default; pass `{ includeGraphOnly: true }` when a custom caller intentionally wants those links included in the same report. + Build an index from an explicit multi-root file list: ```ts diff --git a/docs/scenario-catalog.md b/docs/scenario-catalog.md index 2c16063f..240c02f2 100644 --- a/docs/scenario-catalog.md +++ b/docs/scenario-catalog.md @@ -9,7 +9,7 @@ Minimal catalog of Tree-sitter scenarios with sample coverage. | Rename/move impact seeding | `tests/impact-analyzer.test.ts` (`should seed transitive impact for renamed files`) | Transitive seeding considers both `oldPath` and `path` for renamed files so dependents on either side are included. | Internal regression test | 2026-03-06 | | Binary and mode-only git diff metadata | `tests/streaming-parser.test.ts` | Diff parser records `isBinary`, `modeChanged`, and `similarityIndex` metadata so fallback logic can run when hunks are empty. | Internal regression test | 2026-03-06 | | Impact diagnostics counters | `tests/impact-diagnostics.test.ts` | Impact report includes diagnostics counters (`refsScanned`, filtering counters, fallback counters, symbol mapping counters). | Internal regression test | 2026-03-06 | -| External package filtering | `tests/graph-reports.test.ts`, `tests/cli-regressions.test.ts` | `inspect` and `unresolved` suppress supported-language standard libraries, URL imports, and dependency-manifest packages while bounding manifest traversal to the scoped project boundary. | Internal regression test | 2026-05-02 | +| External package filtering | `tests/graph-reports.test.ts`, `tests/cli-regressions.test.ts` | `inspect` and `unresolved` suppress graph-only document links, supported-language standard libraries, URL imports, and dependency-manifest packages while bounding manifest traversal to the scoped project boundary. | Internal regression test | 2026-05-02 | | SQL review context guardrails | `tests/sql-review-context.test.ts` | SQL facts appear as review context when SQL files or changed SQL literals make them relevant, while stale migrations, seeds, dumps, and fixtures do not create source dependency impact by default. | Internal regression test | 2026-05-12 | ## Native Tree-sitter parity diff --git a/src/graphs/edgeResolution.ts b/src/graphs/edgeResolution.ts index 666d6bc1..4fa79ceb 100644 --- a/src/graphs/edgeResolution.ts +++ b/src/graphs/edgeResolution.ts @@ -111,9 +111,9 @@ export async function resolveModuleSpecifierEdges( return packageTargets.map((targetPath) => withSpecifierMetadata(entry, edgeToResolvedFile(targetPath))); } to = await resolveImportSpecifierEdge(entry, context); - } else if (context.support.id === "go" || context.support.id === "php") { + } else if (context.support.id === "go" || context.support.id === "php" || context.support.id === "rust") { to = await resolveImportSpecifierEdge(entry, context); - } else if (["csharp", "ruby", "rust"].includes(context.support.id)) { + } else if (["csharp", "ruby"].includes(context.support.id)) { const { resolvePathLikeModule } = await import("../util/resolution.js"); const pathLike = await resolvePathLikeModule(context.projectRoot, entry.spec); to = pathLike ? edgeToResolvedFile(pathLike) : await resolveGenericSpecifier(entry, context, resolutionExtensions); diff --git a/src/graphs/unresolved.ts b/src/graphs/unresolved.ts index e7061d0d..35f1ce06 100644 --- a/src/graphs/unresolved.ts +++ b/src/graphs/unresolved.ts @@ -1,4 +1,6 @@ import { builtinModules } from "node:module"; +import { isGraphOnlyLanguage } from "../documentLinks.js"; +import { supportForFile } from "../languages.js"; import type { FileId, Graph } from "../types.js"; import { classifyExternalSpecifier, @@ -15,7 +17,14 @@ function isNodeBuiltinSpecifier(specifier: string): boolean { return NODE_BUILTIN_MODULES.has(specifier); } -export type UnresolvedImportOptions = ExternalSpecifierClassificationOptions; +export type UnresolvedImportOptions = ExternalSpecifierClassificationOptions & { + includeGraphOnly?: boolean; +}; + +function isGraphOnlyImporter(file: FileId): boolean { + const support = supportForFile(file); + return support ? isGraphOnlyLanguage(support.id) : false; +} export function getUnresolvedImports( graph: Graph, @@ -28,6 +37,7 @@ export function getUnresolvedImports( const classificationCache = new Map(); for (const edge of graph.edges) { if (edge.to.type !== "external") continue; + if (!opts.includeGraphOnly && isGraphOnlyImporter(edge.from)) continue; if (isNodeBuiltinSpecifier(edge.to.name) || isNodeBuiltinSpecifier(edge.raw)) continue; const classificationKey = `${edge.from}\0${edge.to.name}\0${edge.raw}\0${opts.projectRoot ?? ""}`; let classification = classificationCache.get(classificationKey); diff --git a/src/languages/definitions/javascript.ts b/src/languages/definitions/javascript.ts index bf8d2978..211d98fd 100644 --- a/src/languages/definitions/javascript.ts +++ b/src/languages/definitions/javascript.ts @@ -110,7 +110,8 @@ export const JAVASCRIPT_DEF: LanguageDefinition = { (import_statement (string) @mod) @stmt (export_statement (string) @mod) @stmt (call_expression function: (import) arguments: (arguments (string) @mod)) @stmt - (call_expression function: (identifier) @fn arguments: (arguments (string) @mod)) (#eq? @fn "require") + ((call_expression function: (identifier) @fn arguments: (arguments (string) @mod)) @stmt + (#eq? @fn "require")) `, exports: ` (export_statement) @stmt diff --git a/src/util/resolution.ts b/src/util/resolution.ts index d594d9da..f59abf77 100644 --- a/src/util/resolution.ts +++ b/src/util/resolution.ts @@ -16,11 +16,13 @@ import { resolveGoImportPath } from "./resolution/go.js"; import { resolveFromNodeModules } from "./resolution/node.js"; import { clearPhpResolutionCaches, getPhpComposerImplicitFiles, resolvePhpImportPath } from "./resolution/php.js"; import { clearPythonResolutionCache, resolvePythonModule } from "./resolution/python.js"; +import { resolveRustImportPath } from "./resolution/rust.js"; import { clearTsconfigCache, loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; export { resolveGoImportPath } from "./resolution/go.js"; export { resolveJvmPackageImportPaths } from "./resolution/jvm.js"; export { getPhpComposerImplicitFiles } from "./resolution/php.js"; export { resolvePythonModule } from "./resolution/python.js"; +export { resolveRustImportPath } from "./resolution/rust.js"; export { loadNearestTsconfigFor, type MatchPathFn } from "./resolution/tsconfig.js"; export { mapLimit } from "./concurrency.js"; export { listResolutionCandidates } from "./resolutionCandidates.js"; @@ -165,6 +167,10 @@ export async function resolveImportSpecifier( const phpResolved = await resolvePhpImportPath(projectRoot, fromFile, spec, opts?.phpImportType); if (phpResolved) return phpResolved; } + if (languageId === "rust") { + const rustResolved = await resolveRustImportPath(projectRoot, fromFile, spec); + if (rustResolved) return rustResolved; + } return resolveSpecifier(fromFile, spec, projectRoot, opts?.matchPath, opts?.workspaceConfig, { resolveNodeModules: !!opts?.resolveNodeModules, diff --git a/src/util/resolution/rust.ts b/src/util/resolution/rust.ts new file mode 100644 index 00000000..09b8c626 --- /dev/null +++ b/src/util/resolution/rust.ts @@ -0,0 +1,96 @@ +import path from "node:path"; +import { fileExists } from "../workspace.js"; + +function isWithinOrEqual(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function findNearestCargoRoot(fromFile: string, projectRoot: string): Promise { + const root = path.resolve(projectRoot); + let dir = path.dirname(path.resolve(fromFile)); + while (isWithinOrEqual(dir, root)) { + if (await fileExists(path.join(dir, "Cargo.toml"))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function normalizeRustModuleSpecifier(spec: string): string { + const compact = spec.replace(/\s+/g, ""); + const braceIndex = compact.indexOf("{"); + const withoutGroup = braceIndex >= 0 ? compact.slice(0, braceIndex).replace(/::$/, "") : compact; + return withoutGroup.endsWith("::*") ? withoutGroup.slice(0, -"::*".length) : withoutGroup; +} + +async function firstExistingFile(candidates: string[]): Promise { + for (const candidate of candidates) { + if (await fileExists(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +function rustModuleCandidates(baseDir: string, parts: readonly string[]): string[] { + if (!parts.length) { + return [path.join(baseDir, "lib.rs"), path.join(baseDir, "main.rs"), path.join(baseDir, "mod.rs")]; + } + const modulePath = path.join(baseDir, ...parts); + return [`${modulePath}.rs`, path.join(modulePath, "mod.rs")]; +} + +function crateSourceRoot(cargoRoot: string | null, projectRoot: string): string { + const root = cargoRoot ?? projectRoot; + return path.join(root, "src"); +} + +async function resolveRustModuleParts(baseDir: string, parts: readonly string[]): Promise { + return firstExistingFile(rustModuleCandidates(baseDir, parts)); +} + +export async function resolveRustImportPath( + projectRoot: string, + fromFile: string, + spec: string, +): Promise { + const normalized = normalizeRustModuleSpecifier(spec); + if (!normalized) return null; + + const parts = normalized.split("::").filter(Boolean); + if (!parts.length) return null; + + const cargoRoot = await findNearestCargoRoot(fromFile, projectRoot); + const sourceRoot = crateSourceRoot(cargoRoot, projectRoot); + const currentDir = path.dirname(fromFile); + const head = parts[0]; + const tail = parts.slice(1); + + if (head === "crate") { + return resolveRustModuleParts(sourceRoot, tail); + } + if (head === "self") { + return resolveRustModuleParts(currentDir, tail); + } + if (head === "super") { + const parentModuleFile = await resolveRustModuleParts(currentDir, []); + if (!tail.length && parentModuleFile) { + return parentModuleFile; + } + const siblingModule = await resolveRustModuleParts(currentDir, tail); + if (siblingModule) { + return siblingModule; + } + return resolveRustModuleParts(path.dirname(currentDir), tail); + } + + const siblingModule = await resolveRustModuleParts(currentDir, parts); + if (siblingModule) { + return siblingModule; + } + return resolveRustModuleParts(sourceRoot, parts); +} diff --git a/src/util/specifiers.ts b/src/util/specifiers.ts index 480ba3a4..d60cc7e2 100644 --- a/src/util/specifiers.ts +++ b/src/util/specifiers.ts @@ -109,12 +109,12 @@ export function extractJsTsSpecifiers(source: string): ModuleSpecifier[] { }; const combined = - /^\s*import\s+[^\n;]*?\s+from\s+["']([^"']+)["']|^\s*import\s+["']([^"']+)["']|\bexport\s+[^\n;]*?\s+from\s+["']([^"']+)["']|\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*require\(\s*["']([^"']+)["']\s*\)|(? { "const req = require('./req')", "const { pick } = require('./pick')", "const dyn = import('./dyn')", + "import legacy = require('./legacy')", + 'declare module "typed-ambient" {}', ].join("\n"); const specs = extractJsTsSpecifiers(source); @@ -79,8 +81,11 @@ describe("Import extraction fallback reporting", () => { "./req", "./pick", "./dyn", + "./legacy", + "typed-ambient", ]); expect(specs[0]?.typeOnly).toBe(true); + expect(specs.at(-1)?.typeOnly).toBe(true); }); it("ignores import and require examples inside string literals", () => { diff --git a/tests/graph-reports.test.ts b/tests/graph-reports.test.ts index d2266989..9d0e88b6 100644 --- a/tests/graph-reports.test.ts +++ b/tests/graph-reports.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { getUnresolvedImports, getHotspots, getApiSurface, SymbolKind } from "../src/index.js"; +import { collectGraph, getUnresolvedImports, getHotspots, getApiSurface, SymbolKind } from "../src/index.js"; import { getExternalClassifierCacheStats, resetExternalClassifierCaches } from "../src/graphs/external-classifier.js"; describe("graph reports", () => { @@ -62,6 +62,25 @@ describe("graph reports", () => { expect(unresolved.map((entry) => entry.name)).toEqual(["react"]); }); + it("excludes graph-only document links from unresolved imports by default", () => { + const root = makeTempRoot("cg-unresolved-doc-links-"); + const sourceFile = path.join(root, "src", "main.ts"); + const docsFile = path.join(root, "notes.md"); + const graphWithDocumentLinks = { + nodes: new Set([sourceFile, docsFile]), + edges: [ + { from: sourceFile, to: { type: "external" as const, name: "./missing-source" }, raw: "./missing-source" }, + { from: docsFile, to: { type: "external" as const, name: "./missing-doc.md" }, raw: "./missing-doc.md" }, + ], + }; + + expect(getUnresolvedImports(graphWithDocumentLinks).map((entry) => entry.name)).toEqual(["./missing-source"]); + expect(getUnresolvedImports(graphWithDocumentLinks, { includeGraphOnly: true }).map((entry) => entry.name)).toEqual([ + "./missing-source", + "./missing-doc.md", + ]); + }); + it("does not count declared JS package dependencies as unresolved imports", () => { const projectRoot = makeTempRoot("cg-unresolved-js-"); fs.writeFileSync( @@ -527,6 +546,35 @@ describe("graph reports", () => { expect(unresolved.map((entry) => entry.name)).toEqual(["missing_module"]); }); + it("does not count resolved Rust crate-relative modules as unresolved imports", async () => { + const projectRoot = makeTempRoot("cg-unresolved-rust-crate-"); + const sourceRoot = path.join(projectRoot, "src"); + fs.mkdirSync(sourceRoot, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "Cargo.toml"), '[package]\nname = "sample"\nversion = "0.1.0"\n', "utf8"); + fs.writeFileSync( + path.join(sourceRoot, "lib.rs"), + 'mod languages;\nmod query;\n#[cfg(test)]\nmod tests;\nuse crate::languages::language_for_id;\n', + "utf8", + ); + fs.writeFileSync(path.join(sourceRoot, "languages.rs"), "pub fn language_for_id() {}\n", "utf8"); + fs.writeFileSync(path.join(sourceRoot, "query.rs"), "pub fn execute_query_cached() {}\n", "utf8"); + fs.writeFileSync( + path.join(sourceRoot, "tests.rs"), + "use super::{language_for_id};\nuse crate::query::{execute_query_cached};\n", + "utf8", + ); + + const files = ["lib.rs", "languages.rs", "query.rs", "tests.rs"].map((file) => path.join(sourceRoot, file)); + const graph = await collectGraph(projectRoot, files); + + expect(getUnresolvedImports(graph, { projectRoot })).toEqual([]); + expect( + graph.edges.some( + (edge) => edge.from.endsWith("tests.rs") && edge.to.type === "file" && edge.to.path.endsWith("query.rs"), + ), + ).toBeTruthy(); + }); + it("should get hotspots", () => { const { root, graph } = makeBasicGraph(); const hotspots = getHotspots(graph); diff --git a/tests/native-query-scope.test.ts b/tests/native-query-scope.test.ts index 33f5b51d..cce2cbfe 100644 --- a/tests/native-query-scope.test.ts +++ b/tests/native-query-scope.test.ts @@ -289,6 +289,23 @@ nativeDescribe("compact imports execution", () => { expect(execution.fallbackReason).toBe("unavailable"); }); + it("does not treat arbitrary JavaScript string arguments as imports", () => { + const support = supportById("js")!; + const source = [ + 'const element = requireElement("graph-container");', + 'const result = spawnSync("npm", ["run", "build"]);', + 'import realImport from "real-package";', + 'const required = require("required-package");', + ].join("\n"); + + const execution = getCompactImportsExecution(source, support); + const specs = collectModuleSpecifiersFromSource(support, undefined, source, { + compactNativeImports: execution.results, + }); + + expect(specs.map((entry) => entry.spec)).toEqual(["real-package", "required-package"]); + }); + it("falls back to regex extraction for Python when compact native imports are empty", () => { const support = supportById("python")!; const specs = collectModuleSpecifiersFromSource(support, undefined, "import os\nfrom pkg import value\n", { From 005b77d660dc8c857a3334c3550e416c3c4b9eb5 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 19:27:43 -0400 Subject: [PATCH 19/25] Document CLI scan scoping --- REVIEW_ANALYSIS_NEXT.md | 2 +- codegraph-skill/codegraph/SKILL.md | 2 +- docs/cli.md | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/REVIEW_ANALYSIS_NEXT.md b/REVIEW_ANALYSIS_NEXT.md index 1cc24851..dea9b600 100644 --- a/REVIEW_ANALYSIS_NEXT.md +++ b/REVIEW_ANALYSIS_NEXT.md @@ -141,7 +141,7 @@ No dependency cycles were reported. The highest remaining concentration is in: ### Documentation -- [ ] Document the intended CLI scoping model. +- [x] Document the intended CLI scoping model. - Explain `--root`, positional include roots, config `ignoreGlobs`, `--include-glob`, `--ignore-glob`, gitignore handling, and cache reuse. - Update `docs/cli.md` and `codegraph-skill/codegraph/SKILL.md` if command behavior or examples change. diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 6b30c7b3..2cb7047c 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -56,7 +56,7 @@ Use `--json` when the output will feed later reasoning, scripts, or another agen Numeric options such as `--limit`, `--threads`, `--depth`, `--max-refs`, and token bounds must be integers in their documented ranges; invalid numeric values fail instead of being silently clamped or ignored. -Project scans read `codegraph.config.json` from `--root` when present. Config `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even for child include-root scans. Use `discovery.ignoreGlobs` for durable repo-local excludes such as large fixtures, generated output, or vendored trees; CLI `--include-glob` and `--ignore-glob` remain additive one-off filters relative to the active scan root. `inspect` follow-up commands preserve the selected `--root` and include roots. `--no-gitignore` opts out of gitignore filtering for a single command. +Project scans read `codegraph.config.json` from `--root` when present. `--root` is the project boundary for config lookup, manifests, path confinement, output paths, and cache/manifest storage. Positional paths after the command are include roots inside that project; for example, `codegraph inspect --root . ./src` scans `src` while keeping `.` as the project root. Config `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even for child include-root scans. Use `discovery.ignoreGlobs` for durable repo-local excludes such as large fixtures, generated output, or vendored trees; CLI `--include-glob` and `--ignore-glob` remain additive one-off filters relative to each active scan root. `--no-gitignore` opts out of gitignore filtering for a single command. Cache and manifest reuse is scoped to `--root` and compatible config/build/graph options; child include-root scans can reuse project-root entries while command summaries and follow-up commands stay scoped to the selected include roots. ## Tool purpose diff --git a/docs/cli.md b/docs/cli.md index 6b509bcb..c6096d6a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,6 +33,14 @@ Commands that scan a project read `codegraph.config.json` from `--root` when it `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even when a command scans child include roots. `discovery.ignoreGlobs` is useful for large fixture, generated, or vendored folders that should not be indexed for search, unresolved-import checks, graphing, impact, or review. CLI `--include-glob` and `--ignore-glob` values are added for a single run; with child include roots, CLI globs stay relative to each scanned root. `inspect` follow-up commands preserve the selected `--root` and include roots. `--no-gitignore` overrides `useGitignore`. +## Scan Scope + +`--root` selects the project boundary for config lookup, package/workspace manifest lookup, path confinement, output path normalization, and cache/manifest storage. Positional path arguments after the command are include roots inside that project. For example, `codegraph inspect --root . ./src ./packages/app` keeps `.` as the project root while limiting the reported scan to `src` and `packages/app`. + +Config globs and one-off CLI globs apply at different layers. `codegraph.config.json` globs are durable and project-root-relative. CLI `--include-glob` and `--ignore-glob` values are additive for a single command and are evaluated relative to each active scan root. `--no-gitignore` disables `.gitignore` filtering for that command only; it does not change config. + +Cache and manifest reuse is rooted at `--root`. Reusing a project root lets commands share compatible index and graph entries when the file signatures, config, graph options, and relevant build options still match. Changing `--root`, changing discovery config, or changing graph options creates a different reuse boundary. Child include-root scans can reuse project-root cache entries, but command summaries and follow-up commands stay scoped to the selected include roots. + ## Core commands ### Dependency graphs From 1eccede99e6bfea44209634970f68259069d6e23 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 20:32:36 -0400 Subject: [PATCH 20/25] Handle synchronous mapLimit failures --- src/util/concurrency.ts | 3 ++- tests/map-limit.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/util/concurrency.ts b/src/util/concurrency.ts index 333769da..a7bbbaf4 100644 --- a/src/util/concurrency.ts +++ b/src/util/concurrency.ts @@ -73,7 +73,8 @@ export async function mapLimit(items: T[], limit: number, fn: (item: T) => const item = items[index]!; activeCount++; - fn(item) + Promise.resolve() + .then(() => fn(item)) .then((result) => { if (aborted) return; results[index] = result; diff --git a/tests/map-limit.test.ts b/tests/map-limit.test.ts index 0914dca8..ca6d2bfa 100644 --- a/tests/map-limit.test.ts +++ b/tests/map-limit.test.ts @@ -39,4 +39,17 @@ describe("mapLimit", () => { expect(started).toEqual([1, 2]); }); + + it("converts synchronous worker throws into promise rejections", async () => { + const started: number[] = []; + await expect( + mapLimit([1, 2, 3], 1, (value) => { + started.push(value); + if (value === 2) throw new Error("sync boom"); + return Promise.resolve(value); + }), + ).rejects.toThrow("sync boom"); + + expect(started).toEqual([1, 2]); + }); }); From b52e5aadeac7bdd142194a6cc94cbd9d85b021d9 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 23:05:16 -0400 Subject: [PATCH 21/25] Address PR feedback on path and resolver handling --- src/util/paths.ts | 29 ++++++++++ src/util/resolution/rust.ts | 16 ++++-- src/util/resolution/tsconfig.ts | 53 ++++++++++++++++-- tests/graph-reports.test.ts | 20 +++++++ tests/path-normalization.test.ts | 9 +++ tests/ts-paths-workspace.test.ts | 94 ++++++++++++++++++++++++++++++++ 6 files changed, 211 insertions(+), 10 deletions(-) diff --git a/src/util/paths.ts b/src/util/paths.ts index 92b02450..d5ac5932 100644 --- a/src/util/paths.ts +++ b/src/util/paths.ts @@ -13,6 +13,11 @@ function isWindowsQualifiedAbsolutePath(filePath: string): boolean { return /^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("//"); } +function isPosixQualifiedAbsolutePath(filePath: string): boolean { + const normalizedPath = normalizePath(filePath); + return path.posix.isAbsolute(normalizedPath) && !isWindowsQualifiedAbsolutePath(normalizedPath); +} + export function isAbsoluteFilePath(filePath: string): boolean { return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); } @@ -24,6 +29,9 @@ export function resolveFilePathFromRoot(projectRoot: string, filePath: string): if (isWindowsQualifiedAbsolutePath(projectRoot)) { return path.win32.resolve(projectRoot, filePath); } + if (isPosixQualifiedAbsolutePath(projectRoot)) { + return path.posix.resolve(normalizePath(projectRoot), normalizePath(filePath)); + } return path.resolve(projectRoot, filePath); } @@ -37,6 +45,8 @@ function resolveComparableProjectRoot(projectRoot: string): string { function isRelativeToRoot(normalizedRoot: string, normalizedFile: string): boolean { const rootIsWindowsPath = isWindowsQualifiedAbsolutePath(normalizedRoot); const fileIsWindowsPath = isWindowsQualifiedAbsolutePath(normalizedFile); + const rootIsPosixPath = isPosixQualifiedAbsolutePath(normalizedRoot); + const fileIsPosixPath = isPosixQualifiedAbsolutePath(normalizedFile); const comparableRoot = rootIsWindowsPath ? normalizeWindowsComparablePath(normalizedRoot) : normalizedRoot; const comparableFile = fileIsWindowsPath ? normalizeWindowsComparablePath(normalizedFile) : normalizedFile; @@ -48,6 +58,22 @@ function isRelativeToRoot(normalizedRoot: string, normalizedFile: string): boole return !!relativePath.length && !relativePath.startsWith("..") && !path.win32.isAbsolute(relativePath); } + if (rootIsWindowsPath || fileIsWindowsPath) { + return false; + } + + if (rootIsPosixPath && fileIsPosixPath) { + if (comparableFile === comparableRoot) { + return true; + } + const relativePath = path.posix.relative(comparableRoot, comparableFile); + return !!relativePath.length && !relativePath.startsWith("..") && !path.posix.isAbsolute(relativePath); + } + + if (rootIsPosixPath || fileIsPosixPath) { + return false; + } + if (comparableFile === comparableRoot) { return true; } @@ -81,6 +107,9 @@ export function toProjectRelativePath(projectRoot: string, filePath: string): st const comparableFile = normalizeWindowsComparablePath(normalizedFile); return normalizePath(path.win32.relative(comparableRoot, comparableFile)); } + if (isPosixQualifiedAbsolutePath(normalizedRoot) && isPosixQualifiedAbsolutePath(normalizedFile)) { + return path.posix.relative(normalizedRoot, normalizedFile); + } return normalizePath(path.relative(normalizedRoot, normalizedFile)); } diff --git a/src/util/resolution/rust.ts b/src/util/resolution/rust.ts index 09b8c626..afa7763a 100644 --- a/src/util/resolution/rust.ts +++ b/src/util/resolution/rust.ts @@ -49,6 +49,13 @@ function crateSourceRoot(cargoRoot: string | null, projectRoot: string): string return path.join(root, "src"); } +function parentRustModuleDir(fromFile: string, currentDir: string): string { + if (path.basename(fromFile) === "mod.rs") { + return path.dirname(currentDir); + } + return currentDir; +} + async function resolveRustModuleParts(baseDir: string, parts: readonly string[]): Promise { return firstExistingFile(rustModuleCandidates(baseDir, parts)); } @@ -77,15 +84,12 @@ export async function resolveRustImportPath( return resolveRustModuleParts(currentDir, tail); } if (head === "super") { - const parentModuleFile = await resolveRustModuleParts(currentDir, []); + const parentModuleDir = parentRustModuleDir(fromFile, currentDir); + const parentModuleFile = await resolveRustModuleParts(parentModuleDir, []); if (!tail.length && parentModuleFile) { return parentModuleFile; } - const siblingModule = await resolveRustModuleParts(currentDir, tail); - if (siblingModule) { - return siblingModule; - } - return resolveRustModuleParts(path.dirname(currentDir), tail); + return resolveRustModuleParts(parentModuleDir, tail); } const siblingModule = await resolveRustModuleParts(currentDir, parts); diff --git a/src/util/resolution/tsconfig.ts b/src/util/resolution/tsconfig.ts index 68deebab..8090dae4 100644 --- a/src/util/resolution/tsconfig.ts +++ b/src/util/resolution/tsconfig.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { createMatchPath } from "tsconfig-paths"; import { logWithLevel, type LogLevel } from "../../logging.js"; @@ -37,7 +38,51 @@ interface TsconfigJson { extends?: string; } -async function loadTsconfigConfig(cfgPath: string): Promise<{ baseUrl: string; paths: Record }> { +function isPathLikeExtendsSpecifier(spec: string): boolean { + return ( + spec.startsWith(".") || + path.posix.isAbsolute(spec) || + path.win32.isAbsolute(spec) || + /^[A-Za-z]:[\\/]/.test(spec) + ); +} + +function localExtendsCandidates(cfgDir: string, spec: string): string[] { + const basePath = path.resolve(cfgDir, spec); + if (basePath.endsWith(".json")) { + return [basePath]; + } + return [basePath, `${basePath}.json`, path.join(basePath, "tsconfig.json")]; +} + +async function resolveTsconfigExtendsPath(cfgDir: string, spec: string): Promise { + if (isPathLikeExtendsSpecifier(spec)) { + for (const candidate of localExtendsCandidates(cfgDir, spec)) { + if (await fileExists(candidate)) { + return candidate; + } + } + return null; + } + + try { + const requireFromConfig = createRequire(path.join(cfgDir, "tsconfig.json")); + return requireFromConfig.resolve(spec); + } catch { + return null; + } +} + +async function loadTsconfigConfig( + cfgPath: string, + seen: Set = new Set(), +): Promise<{ baseUrl: string; paths: Record }> { + const normalizedCfgPath = path.resolve(cfgPath); + if (seen.has(normalizedCfgPath)) { + return { baseUrl: path.dirname(normalizedCfgPath).replace(/\\/g, "/"), paths: {} }; + } + seen.add(normalizedCfgPath); + const raw = await fsp.readFile(cfgPath, "utf8"); const json = parseJsonc(raw); const cfgDir = path.dirname(cfgPath); @@ -47,9 +92,9 @@ async function loadTsconfigConfig(cfgPath: string): Promise<{ baseUrl: string; p const paths: Record = co?.paths ?? {}; if (json.extends) { - const extendsPath = path.resolve(cfgDir, json.extends); - if (await fileExists(extendsPath)) { - const parent = await loadTsconfigConfig(extendsPath); + const extendsPath = await resolveTsconfigExtendsPath(cfgDir, json.extends); + if (extendsPath) { + const parent = await loadTsconfigConfig(extendsPath, seen); const mergedPaths: Record = { ...parent.paths }; for (const [key, patterns] of Object.entries(parent.paths)) { diff --git a/tests/graph-reports.test.ts b/tests/graph-reports.test.ts index 9d0e88b6..27dfc29e 100644 --- a/tests/graph-reports.test.ts +++ b/tests/graph-reports.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { collectGraph, getUnresolvedImports, getHotspots, getApiSurface, SymbolKind } from "../src/index.js"; import { getExternalClassifierCacheStats, resetExternalClassifierCaches } from "../src/graphs/external-classifier.js"; +import { resolveRustImportPath } from "../src/util/resolution.js"; describe("graph reports", () => { const tempRoots: string[] = []; @@ -575,6 +576,25 @@ describe("graph reports", () => { ).toBeTruthy(); }); + it("resolves Rust super imports from mod.rs files against the parent module directory", async () => { + const projectRoot = makeTempRoot("cg-rust-super-mod-"); + const sourceRoot = path.join(projectRoot, "src"); + const nestedRoot = path.join(sourceRoot, "a"); + fs.mkdirSync(nestedRoot, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "Cargo.toml"), '[package]\nname = "sample"\nversion = "0.1.0"\n', "utf8"); + fs.writeFileSync(path.join(sourceRoot, "lib.rs"), "mod a;\nmod b;\n", "utf8"); + fs.writeFileSync(path.join(sourceRoot, "b.rs"), "pub fn root_b() {}\n", "utf8"); + fs.writeFileSync(path.join(nestedRoot, "mod.rs"), "mod b;\nuse super::b;\n", "utf8"); + fs.writeFileSync(path.join(nestedRoot, "b.rs"), "pub fn nested_b() {}\n", "utf8"); + + const nestedModule = path.join(nestedRoot, "mod.rs"); + const resolvedSuperModule = await resolveRustImportPath(projectRoot, nestedModule, "super::b"); + const resolvedSelfModule = await resolveRustImportPath(projectRoot, nestedModule, "self::b"); + + expect(resolvedSuperModule?.replace(/\\/g, "/")).toMatch(/\/src\/b\.rs$/); + expect(resolvedSelfModule?.replace(/\\/g, "/")).toMatch(/\/src\/a\/b\.rs$/); + }); + it("should get hotspots", () => { const { root, graph } = makeBasicGraph(); const hotspots = getHotspots(graph); diff --git a/tests/path-normalization.test.ts b/tests/path-normalization.test.ts index bc3ef552..75a02c54 100644 --- a/tests/path-normalization.test.ts +++ b/tests/path-normalization.test.ts @@ -71,6 +71,15 @@ describe("cross-platform path normalization", () => { expect(toProjectDisplayPath(undefined, String.raw`src\main.ts`)).toBe("src/main.ts"); }); + it("relativizes POSIX absolute paths with POSIX semantics on any host OS", () => { + const root = "/mnt/e/git repos/codegraph"; + + expect(isFilePathWithinRoot(root, "/mnt/e/git repos/codegraph/src/main.ts")).toBe(true); + expect(toProjectRelativePath(root, "/mnt/e/git repos/codegraph/src/main.ts")).toBe("src/main.ts"); + expect(isFilePathWithinRoot(root, "/mnt/e/git repos/codegraph-tools/src/main.ts")).toBe(false); + expect(toProjectRelativePath(root, "/mnt/e/git repos/codegraph-tools/src/main.ts")).toBeNull(); + }); + it("asserts project-root containment with label-specific errors", () => { const root = "C:/workspace/codegraph"; diff --git a/tests/ts-paths-workspace.test.ts b/tests/ts-paths-workspace.test.ts index 8df76fa5..496cdb49 100644 --- a/tests/ts-paths-workspace.test.ts +++ b/tests/ts-paths-workspace.test.ts @@ -74,4 +74,98 @@ describe("TypeScript paths/baseUrl resolution via tsconfig", () => { ), ).toBe(true); }); + + it("resolves path aliases from extensionless local tsconfig extends", async () => { + const root = await mkTmpDir("dg-ts-paths-extends-local-"); + const baseDir = path.join(root, "base"); + const appDir = path.join(root, "app"); + await fsp.mkdir(baseDir, { recursive: true }); + await fsp.mkdir(appDir, { recursive: true }); + await fsp.writeFile( + path.join(root, "tsconfig.base.json"), + JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + paths: { + "@base/*": ["base/*"], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + await fsp.writeFile( + path.join(appDir, "tsconfig.json"), + JSON.stringify({ extends: "../tsconfig.base" }, null, 2), + "utf8", + ); + const util = path.join(baseDir, "util.ts"); + const main = path.join(appDir, "main.ts"); + await fsp.writeFile(util, "export const fn = () => 1;\n", "utf8"); + await fsp.writeFile(main, "import { fn } from '@base/util';\nconst x = fn();\n", "utf8"); + + const normalizedMain = main.replace(/\\/g, "/"); + const normalizedUtil = util.replace(/\\/g, "/"); + const graph = await collectGraph(root, [normalizedMain, normalizedUtil]); + + expect( + graph.edges.some( + (edge) => + edge.from === normalizedMain && + edge.raw === "@base/util" && + edge.to.type === "file" && + edge.to.path === normalizedUtil, + ), + ).toBe(true); + }); + + it("resolves path aliases from package-based tsconfig extends", async () => { + const root = await mkTmpDir("dg-ts-paths-extends-package-"); + const packageDir = path.join(root, "node_modules", "tsconfig-shared"); + const sharedDir = path.join(root, "shared"); + await fsp.mkdir(packageDir, { recursive: true }); + await fsp.mkdir(sharedDir, { recursive: true }); + await fsp.writeFile( + path.join(packageDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + paths: { + "@shared/*": ["../../shared/*"], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + await fsp.writeFile( + path.join(root, "tsconfig.json"), + JSON.stringify({ extends: "tsconfig-shared/tsconfig.json" }, null, 2), + "utf8", + ); + const util = path.join(sharedDir, "util.ts"); + const main = path.join(root, "main.ts"); + await fsp.writeFile(util, "export const fn = () => 1;\n", "utf8"); + await fsp.writeFile(main, "import { fn } from '@shared/util';\nconst x = fn();\n", "utf8"); + + const normalizedMain = main.replace(/\\/g, "/"); + const normalizedUtil = util.replace(/\\/g, "/"); + const graph = await collectGraph(root, [normalizedMain, normalizedUtil]); + + expect( + graph.edges.some( + (edge) => + edge.from === normalizedMain && + edge.raw === "@shared/util" && + edge.to.type === "file" && + edge.to.path === normalizedUtil, + ), + ).toBe(true); + }); }); From 92af468457058ce96e429c72fc8623e8d0131338 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 20 May 2026 23:49:46 -0400 Subject: [PATCH 22/25] Scope Composer classmap exclusions --- AGENTS.md | 1 + docs/language-parity.md | 8 +++- docs/scenario-catalog.md | 1 + src/graphs/cycles.ts | 3 +- src/util/graphOnlyExtensions.ts | 16 +++++++ src/util/resolution.ts | 17 +++---- src/util/resolution/php.ts | 3 +- src/util/resolution/phpComposer.ts | 45 ++++++++++++++----- tests/goto.test.ts | 16 +++++++ tests/languages/php.test.ts | 44 ++++++++++++++++++ tests/references.test.ts | 14 ++++++ .../php/classmap/Excluded/PsrMapped.php | 7 +++ .../php/classmap/Excluded/excluded_helper.php | 6 +++ .../php/composer-excluded-files-consumer.php | 3 ++ .../php/composer-excluded-psr4-consumer.php | 5 +++ tests/samples/php/composer.json | 6 ++- 16 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 src/util/graphOnlyExtensions.ts create mode 100644 tests/samples/php/classmap/Excluded/PsrMapped.php create mode 100644 tests/samples/php/classmap/Excluded/excluded_helper.php create mode 100644 tests/samples/php/composer-excluded-files-consumer.php create mode 100644 tests/samples/php/composer-excluded-psr4-consumer.php diff --git a/AGENTS.md b/AGENTS.md index cea8000d..dcc52afd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - In boolean condition contexts, use the shortest syntactically equivalent expression. Prefer `items.length` over `items.length > 0`, `!items.length` over `items.length === 0`, and `items?.length` over `items && items.length > 0`. - Always consider the impact of a change on tests or when more test cases are needed. Never make tests pass for the sake of passing; always exercise real behavior. - Always keep documentation updated and accurate while being minimal and concise. +- Keep paragraphs to no more than 3 concise sentences. Prefer bullets for dense details. - Keep `README.md` as the landing page and docs index. Do not turn it back into the only canonical reference for every example and workflow. - When public-facing install, runtime, CLI, library API, agent workflow, or release guidance changes, update the relevant canonical docs in the same change: `README.md`, `docs/installation.md`, `docs/cli.md`, `docs/library-api.md`, `docs/agent-workflows.md`, `docs/how-it-works.md`, and `PUBLISHING.md` as applicable. - Within any claimed cross-language capability, behavior should stay consistent across all supported languages for that capability. Avoid language-subset branches; if a limitation is intentional, document it in the parity docs and cover it with explicit tests in the same change. diff --git a/docs/language-parity.md b/docs/language-parity.md index 24becbca..c7d3e8d6 100644 --- a/docs/language-parity.md +++ b/docs/language-parity.md @@ -42,7 +42,13 @@ Notes: - The native addon uses the same Tree-sitter query model as the opt-in `@lzehrung/codegraph-js-fallback` path for all listed source languages. - Native-only installs do not require `@lzehrung/codegraph-js-fallback` for normal supported source-language graph extraction, symbol indexing, chunking, or AST grep. When query recovery degrades in `auto` mode, Codegraph reports it in diagnostics and stays on native-owned recovery paths where the language supports them. - Native parity coverage includes both extraction parity and end-to-end semantic parity on the current source-language fixture set (`TypeScript`, `TSX`, `JavaScript`, `Python`, `PHP`, `Go`, `Java`, `C#`, `Rust`, `Kotlin`, `Swift`, `Zig`, `C`, `C++`, `Ruby`) plus graph/specifier parity for `HTML`, `CSS`, `Less`, `SCSS`, `Vue`, and `Svelte`. -- Deeper hardening coverage now includes Go aliased imports and interface-typed uses, Kotlin alias and wildcard imports plus package-wide wildcard graph expansion and native-owned import-binding recovery, Java wildcard-import package fixtures plus package-wide graph expansion and static wildcard imports, Rust aliased `use` imports plus `extern crate` graph fixtures, C# alias-using graph fixtures, Python `from __future__ import ...` import extraction, PHP grouped `use` imports, bracketed namespace blocks, `__DIR__` include resolution, fully-qualified Composer-backed class/static/type references, function/class basename collision handling, and Composer `psr-0`, `psr-4`, `autoload-dev`, `classmap`, `exclude-from-classmap`, and `autoload.files` resolution with classmap-boundary enforcement, Swift static-member fixtures, Zig `@import` namespace member fixtures, C function-pointer typedef fixtures, C++ namespace/template fixtures, and Ruby nested module fixtures. +- Deeper hardening coverage includes Go aliases and interface-typed uses. +- Deeper hardening coverage includes Kotlin aliases, wildcard imports, package-wide wildcard graph expansion, and native-owned import-binding recovery. +- Deeper hardening coverage includes Java wildcard package fixtures, package-wide graph expansion, and static wildcard imports. +- Deeper hardening coverage includes Rust aliased `use` imports and `extern crate` graph fixtures. +- Deeper hardening coverage includes C# aliases, Swift static members, Zig `@import` namespace members, C function-pointer typedefs, C++ namespace/templates, and Ruby nested modules. +- Deeper hardening coverage includes Python `from __future__ import ...` extraction. +- Deeper hardening coverage includes PHP grouped `use` imports, bracketed namespaces, `__DIR__` includes, fully-qualified Composer-backed references, function/class basename collisions, and Composer classmap-boundary coverage. - JavaScript graphing now includes an isolated AngularJS heuristic layer for `templateUrl`, controller-name, and DI-token file/external edges when a file explicitly uses `angular.module(...)`. This coverage lives in dedicated framework tests, not in the generic JavaScript fixture set, and it is not a general claim that arbitrary `controller` or `templateUrl` config objects are Angular-aware. - `SCSS` uses the native addon for import/specifier extraction. Dependency graph resolution covers Sass partials for extensionless and explicit `.scss` specifiers, including non-canonical extension casing. Native SCSS symbol queries remain intentionally skipped because symbol extraction is not a supported SCSS capability in either runtime path yet. - `HTML`, `CSS`, `Less`, `Vue`, and `Svelte` are graph/chunking-focused today. Their unsupported navigation and symbol features are covered by explicit `not_found` parity tests. diff --git a/docs/scenario-catalog.md b/docs/scenario-catalog.md index 240c02f2..d33e12f5 100644 --- a/docs/scenario-catalog.md +++ b/docs/scenario-catalog.md @@ -194,6 +194,7 @@ Minimal catalog of Tree-sitter scenarios with sample coverage. | Function imports with colliding class names | `tests/samples/php/function-import-consumer.php`, `tests/samples/php/src/Collision/*`, `tests/resolution.test.ts` | `use function` resolution preserves PHP import kind so navigation, references, and graph edges resolve to the function even when a class with the same basename exists in the namespace. | Internal regression fixture | 2026-04-28 | | Composer PSR-0, PSR-4, and autoload-dev resolution | `tests/samples/php/composer-consumer.php`, `tests/resolution.test.ts` | Namespace-qualified imports resolve through `composer.json` `autoload.psr-4`, `autoload-dev.psr-4`, and `psr-0`, including PSR-0 underscore-to-directory class-name mapping. | Internal regression fixture | 2026-04-28 | | Composer classmap and autoload.files resolution | `tests/resolution.test.ts` | Global-namespace class lookups resolve through Composer `classmap`, `exclude-from-classmap` stays enforced for classmap-backed lookups, `autoload.files` entries behave as implicit file dependencies and symbol sources for PHP files in the project, and classmap-backed lookups stay bounded to Composer-declared autoload surfaces. | Internal regression test | 2026-04-28 | +| Composer classmap exclusion boundaries | `tests/samples/php/composer-excluded-*.php`, `tests/languages/php.test.ts`, `tests/goto.test.ts`, `tests/references.test.ts` | `exclude-from-classmap` filters only classmap-origin candidates; PSR-4 mapped classes and `autoload.files` entries remain resolvable even when their files live under an excluded classmap path. | Internal regression fixture | 2026-05-21 | | Fully-qualified Composer-backed references | `tests/samples/php/composer-qualified-consumer.php`, `tests/samples/php/composer-static-*.php`, `tests/samples/php/composer-type-qualified-consumer.php`, `tests/goto.test.ts`, `tests/references.test.ts` | Fully-qualified class references such as `new App\\Domain\\Service()`, `App\\Domain\\Service::make()`, `App\\Domain\\Service::NAME`, `App\\Domain\\Service::$shared`, and typed positions like `App\\Domain\\Service $service` resolve without a `use` statement and contribute navigation/reference hits plus dependency-graph edges where the usage is cross-file relevant. | Internal regression fixture | 2026-04-28 | | Shared semantic coverage | `tests/goto.test.ts`, `tests/references.test.ts` | Imported functions, classes, grouped aliases, fully-qualified Composer-backed class/static/type references, and Composer-mapped symbols resolve through the same shared navigation/reference pipeline used by other source languages. | Internal regression test | 2026-04-28 | diff --git a/src/graphs/cycles.ts b/src/graphs/cycles.ts index 1c4eda95..b9ac7471 100644 --- a/src/graphs/cycles.ts +++ b/src/graphs/cycles.ts @@ -1,4 +1,5 @@ import type { FileId, Graph } from "../types.js"; +import { GRAPH_ONLY_DOCUMENT_EXTENSIONS } from "../util/graphOnlyExtensions.js"; export type CycleInternalEdge = { from: FileId; @@ -20,7 +21,7 @@ export type DetailedCycle = { export type CycleSortMode = "priority" | "size" | "fanin"; -const DOCUMENT_ONLY_CYCLE_EXTENSIONS = new Set([".md", ".mdx", ".rst", ".adoc", ".asciidoc"]); +const DOCUMENT_ONLY_CYCLE_EXTENSIONS = new Set(GRAPH_ONLY_DOCUMENT_EXTENSIONS); function isDocumentOnlyCycleFile(file: string): boolean { const normalized = file.toLowerCase().split(/[?#]/, 1)[0] ?? ""; diff --git a/src/util/graphOnlyExtensions.ts b/src/util/graphOnlyExtensions.ts new file mode 100644 index 00000000..03e96825 --- /dev/null +++ b/src/util/graphOnlyExtensions.ts @@ -0,0 +1,16 @@ +export const GRAPH_ONLY_DOCUMENT_EXTENSIONS = [".md", ".mdx", ".rst", ".adoc", ".asciidoc"] as const; + +export type GraphOnlyDocumentExtension = (typeof GRAPH_ONLY_DOCUMENT_EXTENSIONS)[number]; + +export const GRAPH_ONLY_RESOLUTION_EXTENSIONS = [ + ".md", + ".mdx", + ".astro", + ".hbs", + ".handlebars", + ".rst", + ".adoc", + ".asciidoc", +] as const; + +export type GraphOnlyResolutionExtension = (typeof GRAPH_ONLY_RESOLUTION_EXTENSIONS)[number]; diff --git a/src/util/resolution.ts b/src/util/resolution.ts index f59abf77..bea156af 100644 --- a/src/util/resolution.ts +++ b/src/util/resolution.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; +import { GRAPH_ONLY_RESOLUTION_EXTENSIONS } from "./graphOnlyExtensions.js"; import { normalizePath, normalizeResolutionHints } from "./paths.js"; import { DEFAULT_RESOLUTION_EXTENSIONS, listResolutionCandidates } from "./resolutionCandidates.js"; import { @@ -30,16 +31,12 @@ export { listResolutionCandidates } from "./resolutionCandidates.js"; const resolveSpecifierCache = new Map(); export type FileId = string; -export const GRAPH_ONLY_RESOLUTION_EXTENSIONS = [ - ".md", - ".mdx", - ".astro", - ".hbs", - ".handlebars", - ".rst", - ".adoc", - ".asciidoc", -] as const; +export { + GRAPH_ONLY_DOCUMENT_EXTENSIONS, + GRAPH_ONLY_RESOLUTION_EXTENSIONS, + type GraphOnlyDocumentExtension, + type GraphOnlyResolutionExtension, +} from "./graphOnlyExtensions.js"; const GRAPH_ONLY_LANGUAGE_DOCUMENT_RESOLUTION_EXTENSIONS: Record = { markdown: [".md", ".mdx"], diff --git a/src/util/resolution/php.ts b/src/util/resolution/php.ts index 83bbf3c2..0d9ffb4c 100644 --- a/src/util/resolution/php.ts +++ b/src/util/resolution/php.ts @@ -8,7 +8,6 @@ import { clearPhpComposerResolutionCaches, findPhpComposerPath, getPhpComposerAutoloadFiles, - isPhpComposerClassmapExcluded, loadPhpComposerConfig, resolvePhpPsr0MappedPath, resolvePhpPsr4MappedPath, @@ -567,7 +566,7 @@ export async function resolvePhpImportPath( preferredKind, autoloadFiles, ); - if (symbolResolved && !isPhpComposerClassmapExcluded(symbolResolved, composerConfig)) { + if (symbolResolved) { phpImportResolutionCache.set(cacheKey, symbolResolved); return symbolResolved; } diff --git a/src/util/resolution/phpComposer.ts b/src/util/resolution/phpComposer.ts index ea52aff3..09cc7120 100644 --- a/src/util/resolution/phpComposer.ts +++ b/src/util/resolution/phpComposer.ts @@ -17,6 +17,11 @@ export type PhpComposerConfig = { const phpComposerConfigCache = new Map>(); const phpComposerAutoloadFileCache = new Map>>(); +type PhpComposerAutoloadRoot = { + path: string; + applyClassmapExcludes: boolean; +}; + async function findFirstExistingResolutionCandidate( base: string, resolutionExtensions?: readonly string[], @@ -207,29 +212,45 @@ export async function getPhpComposerAutoloadFiles( const pending = (async () => { const candidates = new Set(); - const roots = new Set([ - ...composerConfig.classmap, - ...composerConfig.files, - ...Array.from(composerConfig.psr4.values()).flat(), - ...Array.from(composerConfig.psr0.values()).flat(), - ]); + const roots: PhpComposerAutoloadRoot[] = []; + const seenRoots = new Set(); + const addRoot = (rootPath: string, applyClassmapExcludes: boolean): void => { + const resolvedRoot = path.resolve(rootPath); + const cacheKey = `${resolvedRoot}\0${applyClassmapExcludes ? "classmap" : "autoload"}`; + if (seenRoots.has(cacheKey)) return; + seenRoots.add(cacheKey); + roots.push({ path: resolvedRoot, applyClassmapExcludes }); + }; + + for (const root of composerConfig.classmap) { + addRoot(root, true); + } + for (const root of composerConfig.files) { + addRoot(root, false); + } + for (const root of Array.from(composerConfig.psr4.values()).flat()) { + addRoot(root, true); + } + for (const root of Array.from(composerConfig.psr0.values()).flat()) { + addRoot(root, true); + } for (const root of roots) { try { - const stat = await fsp.stat(root); + const stat = await fsp.stat(root.path); if (stat.isDirectory()) { - const files = await listProjectFiles(root, ["**/*.php"]); + const files = await listProjectFiles(root.path, ["**/*.php"]); for (const filePath of files) { - if (isPhpComposerClassmapExcluded(filePath, composerConfig)) { + if (root.applyClassmapExcludes && isPhpComposerClassmapExcluded(filePath, composerConfig)) { continue; } candidates.add(path.resolve(filePath)); } continue; } - if (stat.isFile() && root.toLowerCase().endsWith(".php")) { - if (isPhpComposerClassmapExcluded(root, composerConfig)) continue; - candidates.add(path.resolve(root)); + if (stat.isFile() && root.path.toLowerCase().endsWith(".php")) { + if (root.applyClassmapExcludes && isPhpComposerClassmapExcluded(root.path, composerConfig)) continue; + candidates.add(path.resolve(root.path)); } } catch { // Ignore missing Composer autoload roots. diff --git a/tests/goto.test.ts b/tests/goto.test.ts index a38487ed..3103fed4 100644 --- a/tests/goto.test.ts +++ b/tests/goto.test.ts @@ -610,6 +610,14 @@ describe("Go to Definition", () => { path.join(samplePath, "dev-classmap", "DevSpecific.php").replace(/\\/g, "/"), 5, ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-excluded-psr4-consumer.php").replace(/\\/g, "/"), + 5, + 6, + path.join(samplePath, "classmap", "Excluded", "PsrMapped.php").replace(/\\/g, "/"), + 5, + ); await testGoToDefinition( index, path.join(samplePath, "composer-files-consumer.php").replace(/\\/g, "/"), @@ -618,6 +626,14 @@ describe("Go to Definition", () => { path.join(samplePath, "autoload", "global_helper.php").replace(/\\/g, "/"), 3, ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-excluded-files-consumer.php").replace(/\\/g, "/"), + 3, + 3, + path.join(samplePath, "classmap", "Excluded", "excluded_helper.php").replace(/\\/g, "/"), + 3, + ); }); it("should not resolve Composer classes excluded from classmap", async () => { diff --git a/tests/languages/php.test.ts b/tests/languages/php.test.ts index e1d473ed..c8e7ce0d 100644 --- a/tests/languages/php.test.ts +++ b/tests/languages/php.test.ts @@ -81,6 +81,10 @@ const definition: LanguageTestDefinition = { from: "composer-dev-classmap-consumer.php", to: { type: "file", path: "dev-classmap/DevSpecific.php" }, }, + { + from: "composer-excluded-psr4-consumer.php", + to: { type: "file", path: "classmap/Excluded/PsrMapped.php" }, + }, { from: "composer-files-consumer.php", to: { type: "file", path: "autoload/global_helper.php" }, @@ -89,6 +93,10 @@ const definition: LanguageTestDefinition = { from: "composer-files-consumer.php", to: { type: "file", path: "autoload/dev_helper.php" }, }, + { + from: "composer-excluded-files-consumer.php", + to: { type: "file", path: "classmap/Excluded/excluded_helper.php" }, + }, { from: "function-import-consumer.php", to: { type: "file", path: "src/Collision/ThingFunction.php" }, @@ -157,6 +165,10 @@ const definition: LanguageTestDefinition = { file: "dev-classmap/DevSpecific.php", includes: [{ name: "DevSpecific" }], }, + { + file: "classmap/Excluded/PsrMapped.php", + includes: [{ name: "PsrMapped" }], + }, { file: "autoload/global_helper.php", includes: [{ name: "global_helper" }], @@ -165,6 +177,10 @@ const definition: LanguageTestDefinition = { file: "autoload/dev_helper.php", includes: [{ name: "dev_helper" }], }, + { + file: "classmap/Excluded/excluded_helper.php", + includes: [{ name: "excluded_helper" }], + }, { file: "multi-namespace/Library.php", includes: [{ name: "FirstService" }, { name: "SecondService" }], @@ -283,6 +299,13 @@ const definition: LanguageTestDefinition = { column: 6, expectedStatus: "not_found", }, + { + name: "go to definition resolves PSR-4 classes inside Composer excluded classmap paths", + file: "composer-excluded-psr4-consumer.php", + line: 5, + column: 6, + expectedDefinition: { file: "classmap/Excluded/PsrMapped.php", line: 5 }, + }, { name: "go to definition resolves Composer files autoload functions", file: "composer-files-consumer.php", @@ -297,6 +320,13 @@ const definition: LanguageTestDefinition = { column: 3, expectedDefinition: { file: "autoload/dev_helper.php", line: 3 }, }, + { + name: "go to definition resolves Composer files inside excluded classmap paths", + file: "composer-excluded-files-consumer.php", + line: 3, + column: 3, + expectedDefinition: { file: "classmap/Excluded/excluded_helper.php", line: 3 }, + }, { name: "go to definition respects PHP function import kind", file: "function-import-consumer.php", @@ -390,6 +420,13 @@ const definition: LanguageTestDefinition = { column: 7, minimumCount: 2, }, + { + name: "find references for PSR-4 classes inside Composer excluded classmap paths", + file: "classmap/Excluded/PsrMapped.php", + line: 5, + column: 7, + minimumCount: 2, + }, { name: "find references for Composer files autoload functions", file: "autoload/global_helper.php", @@ -404,6 +441,13 @@ const definition: LanguageTestDefinition = { column: 10, minimumCount: 2, }, + { + name: "find references for Composer files inside excluded classmap paths", + file: "classmap/Excluded/excluded_helper.php", + line: 3, + column: 10, + minimumCount: 2, + }, { name: "find references for PHP classes from bracketed namespace blocks", file: "multi-namespace/Library.php", diff --git a/tests/references.test.ts b/tests/references.test.ts index 1fd2af14..6d4e7f98 100644 --- a/tests/references.test.ts +++ b/tests/references.test.ts @@ -613,6 +613,13 @@ describe("Find References", () => { referenceFile: path.join(samplePath, "composer-dev-classmap-consumer.php").replace(/\\/g, "/"), referenceLine: 5, }, + { + definitionFile: path.join(samplePath, "classmap", "Excluded", "PsrMapped.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 7, + referenceFile: path.join(samplePath, "composer-excluded-psr4-consumer.php").replace(/\\/g, "/"), + referenceLine: 5, + }, { definitionFile: path.join(samplePath, "autoload", "global_helper.php").replace(/\\/g, "/"), definitionLine: 3, @@ -620,6 +627,13 @@ describe("Find References", () => { referenceFile: path.join(samplePath, "composer-files-consumer.php").replace(/\\/g, "/"), referenceLine: 3, }, + { + definitionFile: path.join(samplePath, "classmap", "Excluded", "excluded_helper.php").replace(/\\/g, "/"), + definitionLine: 3, + definitionColumn: 10, + referenceFile: path.join(samplePath, "composer-excluded-files-consumer.php").replace(/\\/g, "/"), + referenceLine: 3, + }, ]; for (const testCase of cases) { diff --git a/tests/samples/php/classmap/Excluded/PsrMapped.php b/tests/samples/php/classmap/Excluded/PsrMapped.php new file mode 100644 index 00000000..efdbeadd --- /dev/null +++ b/tests/samples/php/classmap/Excluded/PsrMapped.php @@ -0,0 +1,7 @@ + Date: Thu, 21 May 2026 00:31:54 -0400 Subject: [PATCH 23/25] Preserve PSR and Rust module resolution --- src/graphs/edgeResolution.ts | 12 ++++ src/graphs/specifiers.ts | 16 ++++- src/util/resolution/phpComposer.ts | 64 ++++++++++++++++--- tests/goto.test.ts | 10 ++- tests/graph-reports.test.ts | 11 ++++ tests/languages/php.test.ts | 24 ++++++- tests/references.test.ts | 9 ++- .../php/classmap/Excluded/psr_helper.php | 8 +++ .../php/composer-excluded-psr4-consumer.php | 2 + 9 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 tests/samples/php/classmap/Excluded/psr_helper.php diff --git a/src/graphs/edgeResolution.ts b/src/graphs/edgeResolution.ts index 4fa79ceb..6be1d013 100644 --- a/src/graphs/edgeResolution.ts +++ b/src/graphs/edgeResolution.ts @@ -79,6 +79,18 @@ async function resolveImportSpecifierEdge( entry: ModuleSpecifier, context: ModuleSpecifierResolutionContext, ): Promise { + if (context.support.id === "rust" && entry.raw && entry.raw !== entry.spec) { + const rawResolved = await resolveImportSpecifier(context.projectRoot, context.file, entry.raw, context.support.id, { + ...(context.matchPath ? { matchPath: context.matchPath } : {}), + ...(context.workspaceConfig ? { workspaceConfig: context.workspaceConfig } : {}), + resolveNodeModules: !!context.resolveNodeModules, + ...(context.resolutionHints ? { resolutionHints: context.resolutionHints } : {}), + }); + if (typeof rawResolved === "string") { + return edgeToResolvedFile(rawResolved); + } + } + const res = await resolveImportSpecifier(context.projectRoot, context.file, entry.spec, context.support.id, { ...(context.matchPath ? { matchPath: context.matchPath } : {}), ...(context.workspaceConfig ? { workspaceConfig: context.workspaceConfig } : {}), diff --git a/src/graphs/specifiers.ts b/src/graphs/specifiers.ts index 71e4d5c4..eceeaddd 100644 --- a/src/graphs/specifiers.ts +++ b/src/graphs/specifiers.ts @@ -11,6 +11,7 @@ import { parseKotlinImportStatement, parsePhpImportStatement, parseRustImportStatement, + type ParsedRustImportStatement, } from "../languages/importStatementParsers.js"; import type { SyntaxNodeLike, SyntaxTreeLike } from "../languages/types.js"; import { logWithLevel, type LogLevel } from "../logging.js"; @@ -62,6 +63,17 @@ function isHtmlLikeLanguage(languageId: string, filePath?: string): boolean { return !!filePath && filePath.toLowerCase().endsWith(".astro"); } +function rustSpecifierFromParsedImport(parsed: ParsedRustImportStatement): ModuleSpecifier { + if (parsed.kind !== "member") { + return { spec: parsed.from, typeOnly: false }; + } + const root = parsed.from.split("::", 1)[0] ?? ""; + if (root === "crate" || root === "self" || root === "super") { + return { spec: parsed.from, raw: `${parsed.from}::${parsed.imported}`, typeOnly: false }; + } + return { spec: parsed.from, typeOnly: false }; +} + function extractPhpQualifiedSpecifiersFromTree(source: string, tree: SyntaxTreeLike): ModuleSpecifier[] { const specifiers: ModuleSpecifier[] = []; const seen = new Set(); @@ -321,7 +333,7 @@ export function collectModuleSpecifiersFromSource( if (support.id === "rust") { const parsed = parseRustImportStatement(stmtText); if (parsed) { - out.push({ spec: parsed.from, typeOnly: false }); + out.push(rustSpecifierFromParsedImport(parsed)); continue; } } @@ -408,7 +420,7 @@ export function collectModuleSpecifiersFromSource( if (support.id === "rust") { const parsed = parseRustImportStatement(stmtText); if (parsed) { - out.push({ spec: parsed.from, typeOnly: false }); + out.push(rustSpecifierFromParsedImport(parsed)); continue; } } diff --git a/src/util/resolution/phpComposer.ts b/src/util/resolution/phpComposer.ts index 09cc7120..db10f9d7 100644 --- a/src/util/resolution/phpComposer.ts +++ b/src/util/resolution/phpComposer.ts @@ -20,6 +20,7 @@ const phpComposerAutoloadFileCache = new Map>>(); type PhpComposerAutoloadRoot = { path: string; applyClassmapExcludes: boolean; + namespacePrefixes: string[]; }; async function findFirstExistingResolutionCandidate( @@ -213,13 +214,25 @@ export async function getPhpComposerAutoloadFiles( const pending = (async () => { const candidates = new Set(); const roots: PhpComposerAutoloadRoot[] = []; - const seenRoots = new Set(); - const addRoot = (rootPath: string, applyClassmapExcludes: boolean): void => { + const addRoot = (rootPath: string, applyClassmapExcludes: boolean, namespacePrefix?: string): void => { const resolvedRoot = path.resolve(rootPath); - const cacheKey = `${resolvedRoot}\0${applyClassmapExcludes ? "classmap" : "autoload"}`; - if (seenRoots.has(cacheKey)) return; - seenRoots.add(cacheKey); - roots.push({ path: resolvedRoot, applyClassmapExcludes }); + const existingRoot = roots.find( + (root) => root.path === resolvedRoot && root.applyClassmapExcludes === applyClassmapExcludes, + ); + const normalizedPrefix = normalizePhpNamespacePrefix(namespacePrefix); + if (existingRoot) { + if (normalizedPrefix === undefined) { + existingRoot.namespacePrefixes = [""]; + } else if (!existingRoot.namespacePrefixes.includes("") && !existingRoot.namespacePrefixes.includes(normalizedPrefix)) { + existingRoot.namespacePrefixes.push(normalizedPrefix); + } + return; + } + roots.push({ + path: resolvedRoot, + applyClassmapExcludes, + namespacePrefixes: normalizedPrefix === undefined ? [""] : [normalizedPrefix], + }); }; for (const root of composerConfig.classmap) { @@ -228,11 +241,15 @@ export async function getPhpComposerAutoloadFiles( for (const root of composerConfig.files) { addRoot(root, false); } - for (const root of Array.from(composerConfig.psr4.values()).flat()) { - addRoot(root, true); + for (const [prefix, dirs] of composerConfig.psr4) { + for (const root of dirs) { + addRoot(root, false, prefix); + } } - for (const root of Array.from(composerConfig.psr0.values()).flat()) { - addRoot(root, true); + for (const [prefix, dirs] of composerConfig.psr0) { + for (const root of dirs) { + addRoot(root, false, prefix); + } } for (const root of roots) { @@ -241,6 +258,9 @@ export async function getPhpComposerAutoloadFiles( if (stat.isDirectory()) { const files = await listProjectFiles(root.path, ["**/*.php"]); for (const filePath of files) { + if (!(await phpFileMatchesNamespacePrefixes(filePath, root.namespacePrefixes))) { + continue; + } if (root.applyClassmapExcludes && isPhpComposerClassmapExcluded(filePath, composerConfig)) { continue; } @@ -249,6 +269,7 @@ export async function getPhpComposerAutoloadFiles( continue; } if (stat.isFile() && root.path.toLowerCase().endsWith(".php")) { + if (!(await phpFileMatchesNamespacePrefixes(root.path, root.namespacePrefixes))) continue; if (root.applyClassmapExcludes && isPhpComposerClassmapExcluded(root.path, composerConfig)) continue; candidates.add(path.resolve(root.path)); } @@ -264,6 +285,29 @@ export async function getPhpComposerAutoloadFiles( return await pending; } +function normalizePhpNamespacePrefix(prefix: string | undefined): string | undefined { + if (prefix === undefined) return undefined; + return prefix.replace(/^\\+|\\+$/g, ""); +} + +async function phpFileMatchesNamespacePrefixes(filePath: string, namespacePrefixes: readonly string[]): Promise { + if (!namespacePrefixes.length || namespacePrefixes.includes("")) { + return true; + } + let source: string; + try { + source = await fsp.readFile(filePath, "utf8"); + } catch { + return false; + } + const namespaceMatch = source.match(/\bnamespace\s+([^;{]+)/); + const namespaceName = namespaceMatch?.[1]?.trim().replace(/^\\+|\\+$/g, ""); + if (namespaceName === undefined) { + return false; + } + return namespacePrefixes.some((prefix) => namespaceName === prefix || namespaceName.startsWith(`${prefix}\\`)); +} + export function isPhpComposerClassmapExcluded(filePath: string, composerConfig: PhpComposerConfig): boolean { const normalizedFile = normalizePath(path.resolve(filePath)); return composerConfig.excludeFromClassmap.some((entry) => { diff --git a/tests/goto.test.ts b/tests/goto.test.ts index 3103fed4..af67b250 100644 --- a/tests/goto.test.ts +++ b/tests/goto.test.ts @@ -613,11 +613,19 @@ describe("Go to Definition", () => { await testGoToDefinition( index, path.join(samplePath, "composer-excluded-psr4-consumer.php").replace(/\\/g, "/"), - 5, + 6, 6, path.join(samplePath, "classmap", "Excluded", "PsrMapped.php").replace(/\\/g, "/"), 5, ); + await testGoToDefinition( + index, + path.join(samplePath, "composer-excluded-psr4-consumer.php").replace(/\\/g, "/"), + 7, + 3, + path.join(samplePath, "classmap", "Excluded", "psr_helper.php").replace(/\\/g, "/"), + 5, + ); await testGoToDefinition( index, path.join(samplePath, "composer-files-consumer.php").replace(/\\/g, "/"), diff --git a/tests/graph-reports.test.ts b/tests/graph-reports.test.ts index 27dfc29e..4f63eb04 100644 --- a/tests/graph-reports.test.ts +++ b/tests/graph-reports.test.ts @@ -590,9 +590,20 @@ describe("graph reports", () => { const nestedModule = path.join(nestedRoot, "mod.rs"); const resolvedSuperModule = await resolveRustImportPath(projectRoot, nestedModule, "super::b"); const resolvedSelfModule = await resolveRustImportPath(projectRoot, nestedModule, "self::b"); + const files = ["lib.rs", "b.rs", "a/mod.rs", "a/b.rs"].map((file) => path.join(sourceRoot, file)); + const graph = await collectGraph(projectRoot, files); expect(resolvedSuperModule?.replace(/\\/g, "/")).toMatch(/\/src\/b\.rs$/); expect(resolvedSelfModule?.replace(/\\/g, "/")).toMatch(/\/src\/a\/b\.rs$/); + expect( + graph.edges.some( + (edge) => + edge.from.endsWith("a/mod.rs") && + edge.raw === "super::b" && + edge.to.type === "file" && + edge.to.path.endsWith("src/b.rs"), + ), + ).toBeTruthy(); }); it("should get hotspots", () => { diff --git a/tests/languages/php.test.ts b/tests/languages/php.test.ts index c8e7ce0d..44054163 100644 --- a/tests/languages/php.test.ts +++ b/tests/languages/php.test.ts @@ -85,6 +85,10 @@ const definition: LanguageTestDefinition = { from: "composer-excluded-psr4-consumer.php", to: { type: "file", path: "classmap/Excluded/PsrMapped.php" }, }, + { + from: "composer-excluded-psr4-consumer.php", + to: { type: "file", path: "classmap/Excluded/psr_helper.php" }, + }, { from: "composer-files-consumer.php", to: { type: "file", path: "autoload/global_helper.php" }, @@ -169,6 +173,10 @@ const definition: LanguageTestDefinition = { file: "classmap/Excluded/PsrMapped.php", includes: [{ name: "PsrMapped" }], }, + { + file: "classmap/Excluded/psr_helper.php", + includes: [{ name: "psr_helper" }], + }, { file: "autoload/global_helper.php", includes: [{ name: "global_helper" }], @@ -302,10 +310,17 @@ const definition: LanguageTestDefinition = { { name: "go to definition resolves PSR-4 classes inside Composer excluded classmap paths", file: "composer-excluded-psr4-consumer.php", - line: 5, + line: 6, column: 6, expectedDefinition: { file: "classmap/Excluded/PsrMapped.php", line: 5 }, }, + { + name: "go to definition resolves PSR-4 functions inside Composer excluded classmap paths", + file: "composer-excluded-psr4-consumer.php", + line: 7, + column: 3, + expectedDefinition: { file: "classmap/Excluded/psr_helper.php", line: 5 }, + }, { name: "go to definition resolves Composer files autoload functions", file: "composer-files-consumer.php", @@ -427,6 +442,13 @@ const definition: LanguageTestDefinition = { column: 7, minimumCount: 2, }, + { + name: "find references for PSR-4 functions inside Composer excluded classmap paths", + file: "classmap/Excluded/psr_helper.php", + line: 5, + column: 10, + minimumCount: 2, + }, { name: "find references for Composer files autoload functions", file: "autoload/global_helper.php", diff --git a/tests/references.test.ts b/tests/references.test.ts index 6d4e7f98..6a17c6d6 100644 --- a/tests/references.test.ts +++ b/tests/references.test.ts @@ -618,7 +618,14 @@ describe("Find References", () => { definitionLine: 5, definitionColumn: 7, referenceFile: path.join(samplePath, "composer-excluded-psr4-consumer.php").replace(/\\/g, "/"), - referenceLine: 5, + referenceLine: 6, + }, + { + definitionFile: path.join(samplePath, "classmap", "Excluded", "psr_helper.php").replace(/\\/g, "/"), + definitionLine: 5, + definitionColumn: 10, + referenceFile: path.join(samplePath, "composer-excluded-psr4-consumer.php").replace(/\\/g, "/"), + referenceLine: 7, }, { definitionFile: path.join(samplePath, "autoload", "global_helper.php").replace(/\\/g, "/"), diff --git a/tests/samples/php/classmap/Excluded/psr_helper.php b/tests/samples/php/classmap/Excluded/psr_helper.php new file mode 100644 index 00000000..2597c6b4 --- /dev/null +++ b/tests/samples/php/classmap/Excluded/psr_helper.php @@ -0,0 +1,8 @@ + Date: Thu, 21 May 2026 01:01:07 -0400 Subject: [PATCH 24/25] Scope Python resolution cache by root --- docs/cli.md | 6 +++++- src/util/resolution/python.ts | 8 +++++++- tests/resolution.test.ts | 23 ++++++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c6096d6a..d350815e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -31,7 +31,11 @@ Commands that scan a project read `codegraph.config.json` from `--root` when it } ``` -`discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even when a command scans child include roots. `discovery.ignoreGlobs` is useful for large fixture, generated, or vendored folders that should not be indexed for search, unresolved-import checks, graphing, impact, or review. CLI `--include-glob` and `--ignore-glob` values are added for a single run; with child include roots, CLI globs stay relative to each scanned root. `inspect` follow-up commands preserve the selected `--root` and include roots. `--no-gitignore` overrides `useGitignore`. +- `discovery.includeGlobs` and `discovery.ignoreGlobs` are project-root-relative, even when a command scans child include roots. +- `discovery.ignoreGlobs` is for large fixture, generated, or vendored folders that should not be indexed. +- CLI `--include-glob` and `--ignore-glob` values are one-off additions and stay relative to each scanned root. +- `inspect` follow-up commands preserve the selected `--root` and include roots. +- `--no-gitignore` overrides `useGitignore`. ## Scan Scope diff --git a/src/util/resolution/python.ts b/src/util/resolution/python.ts index 4d0f519d..d79341dc 100644 --- a/src/util/resolution/python.ts +++ b/src/util/resolution/python.ts @@ -8,6 +8,12 @@ type FileId = string; const resolvePythonModuleCache = new Map(); +function pythonResolutionCacheKey(projectRoot: string, fromFile: string, moduleName: string | null, importDotCount: number): string { + const normalizedRoot = normalizePath(path.resolve(projectRoot)); + const normalizedFromFile = normalizePath(path.resolve(fromFile)); + return `${normalizedRoot}::${normalizedFromFile}::${".".repeat(importDotCount)}${moduleName ?? ""}`; +} + async function findPythonPackageAnchor(startDir: string): Promise { let dir = startDir; let topWithInit = startDir; @@ -31,7 +37,7 @@ export async function resolvePythonModule( moduleName: string | null, importDotCount: number, ): Promise { - const cacheKey = `${fromFile}::${".".repeat(importDotCount)}${moduleName ?? ""}`; + const cacheKey = pythonResolutionCacheKey(projectRoot, fromFile, moduleName, importDotCount); const cached = resolvePythonModuleCache.get(cacheKey); if (cached) return cached; const fromDir = path.dirname(fromFile); diff --git a/tests/resolution.test.ts b/tests/resolution.test.ts index 1cb55174..d6e5778c 100644 --- a/tests/resolution.test.ts +++ b/tests/resolution.test.ts @@ -3,7 +3,13 @@ import path from "node:path"; import os from "node:os"; import fsp from "node:fs/promises"; import { buildProjectIndex, clearImportResolutionCaches, collectGraph, goToDefinition } from "../src/index.js"; -import { loadNearestTsconfigFor, loadWorkspaceConfig, resolveSpecifier, resolveWorkspacePackage } from "../src/util.js"; +import { + loadNearestTsconfigFor, + loadWorkspaceConfig, + resolvePythonModule, + resolveSpecifier, + resolveWorkspacePackage, +} from "../src/util.js"; async function mkTmpDir(prefix: string): Promise { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -224,6 +230,21 @@ describe("Import Resolution", () => { await expect(resolveSpecifier(fromFile, "/target", rootB)).resolves.toBe(targetB); }); + it("keys Python module resolution by project root", async () => { + const rootA = await mkTmpDir("dg-resolve-python-cache-root-a-"); + const rootB = await mkTmpDir("dg-resolve-python-cache-root-b-"); + const fromFile = path.join(await mkTmpDir("dg-resolve-python-cache-from-"), "main.py"); + const targetA = path.join(rootA, "target.py"); + const targetB = path.join(rootB, "target.py"); + + clearImportResolutionCaches(); + await fsp.writeFile(targetA, "VALUE = 'a'\n", "utf8"); + await fsp.writeFile(targetB, "VALUE = 'b'\n", "utf8"); + + await expect(resolvePythonModule(rootA, fromFile, "target", 0)).resolves.toBe(targetA.replace(/\\/g, "/")); + await expect(resolvePythonModule(rootB, fromFile, "target", 0)).resolves.toBe(targetB.replace(/\\/g, "/")); + }); + it("does not resolve tsconfig path aliases to directories without entry files", async () => { const root = await mkTmpDir("dg-resolve-tsconfig-directory-"); const appFile = path.join(root, "src", "app.ts"); From c7b2386246bd7c7e3516a8104560099d8a42d85d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Thu, 21 May 2026 01:23:26 -0400 Subject: [PATCH 25/25] Address review feedback on lookup caches --- src/agent/artifact.ts | 3 ++- src/agent/normalize.ts | 7 ++++++- src/agent/session.ts | 3 +++ src/util/concurrency.ts | 4 ++-- src/util/resolution/phpComposer.ts | 13 +++++++++---- tests/agent-normalize.test.ts | 19 +++++++++++++++++++ tests/resolution.test.ts | 3 +++ 7 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 tests/agent-normalize.test.ts diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 6e9da258..207db125 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -6,7 +6,7 @@ import { defNodeId } from "../graphs/symbol-graph.js"; import { queryGraphSqliteRaw, writeGraphSqlite } from "../sqlite.js"; import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "../util/paths.js"; import { formatAgentSqlHandle, formatAgentSymbolHandle } from "./handles.js"; -import { normalizeAgentFilePath } from "./normalize.js"; +import { createAgentFileLookup, normalizeAgentFilePath } from "./normalize.js"; import { createAgentSession } from "./session.js"; import type { AgentProjectSnapshot, AgentSession } from "./session.js"; import { quoteShellArg } from "./shell.js"; @@ -542,6 +542,7 @@ async function filterSnapshotForOutputDirectory( return { ...snapshot, files, + fileLookup: createAgentFileLookup(files), index, fileGraph, symbolGraph, diff --git a/src/agent/normalize.ts b/src/agent/normalize.ts index 3fec7024..cc72fd6d 100644 --- a/src/agent/normalize.ts +++ b/src/agent/normalize.ts @@ -5,6 +5,7 @@ import { quoteShellArg } from "./shell.js"; export type AgentFileSnapshot = { root: string; files: readonly string[]; + fileLookup?: ReadonlyMap; }; export type AgentSqlObjectKind = "table" | "view" | "index" | "routine"; @@ -17,8 +18,12 @@ export function normalizeAgentOutputPath(root: string, file: string): string { return toProjectDisplayPath(root, file); } +export function createAgentFileLookup(files: readonly string[]): Map { + return new Map(files.map((file) => [normalizePath(file), normalizePath(file)])); +} + export function resolveAgentSnapshotFile(snapshot: AgentFileSnapshot, candidate: string): string | null { - const normalizedFiles = new Map(snapshot.files.map((file) => [normalizePath(file), normalizePath(file)])); + const normalizedFiles = snapshot.fileLookup ?? createAgentFileLookup(snapshot.files); const absoluteCandidate = path.isAbsolute(candidate) ? normalizePath(candidate) : normalizePath(path.resolve(snapshot.root, candidate)); diff --git a/src/agent/session.ts b/src/agent/session.ts index e04f05c5..e61d7713 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -5,10 +5,12 @@ import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; +import { createAgentFileLookup } from "./normalize.js"; export type AgentProjectSnapshot = { root: string; files: string[]; + fileLookup?: ReadonlyMap; index: ProjectIndex; fileGraph: Graph; symbolGraph: SymbolGraph; @@ -49,6 +51,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { return { root: options.root, files, + fileLookup: createAgentFileLookup(files), index, fileGraph, symbolGraph, diff --git a/src/util/concurrency.ts b/src/util/concurrency.ts index a7bbbaf4..9262bc4e 100644 --- a/src/util/concurrency.ts +++ b/src/util/concurrency.ts @@ -76,9 +76,9 @@ export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise.resolve() .then(() => fn(item)) .then((result) => { + activeCount--; if (aborted) return; results[index] = result; - activeCount--; if (nextIndex < items.length) { startNext(); } else if (activeCount === 0 && resolveAll) { @@ -86,9 +86,9 @@ export async function mapLimit(items: T[], limit: number, fn: (item: T) => } }) .catch((err) => { + activeCount--; if (aborted) return; aborted = true; - activeCount--; if (rejectAll) rejectAll(err); }); } diff --git a/src/util/resolution/phpComposer.ts b/src/util/resolution/phpComposer.ts index db10f9d7..6f3bd903 100644 --- a/src/util/resolution/phpComposer.ts +++ b/src/util/resolution/phpComposer.ts @@ -11,6 +11,7 @@ export type PhpComposerConfig = { psr0: Map; classmap: string[]; excludeFromClassmap: string[]; + classmapExcludePrefixes: string[]; files: string[]; }; @@ -81,6 +82,10 @@ function readComposerStringList(value: unknown, composerDir: string): string[] { .map((entry) => resolveComposerPath(entry, composerDir)); } +function normalizeComposerClassmapExcludePrefix(entry: string): string { + return normalizePath(path.resolve(entry)).replace(/\/+$/, ""); +} + export async function loadPhpComposerConfig(composerPath: string): Promise { const cached = phpComposerConfigCache.get(composerPath); if (cached) return await cached; @@ -113,12 +118,13 @@ export async function loadPhpComposerConfig(composerPath: string): Promise { - const normalizedEntry = normalizePath(path.resolve(entry)).replace(/\/+$/, ""); - return normalizedFile === normalizedEntry || normalizedFile.startsWith(`${normalizedEntry}/`); + return composerConfig.classmapExcludePrefixes.some((prefix) => { + return normalizedFile === prefix || normalizedFile.startsWith(`${prefix}/`); }); } diff --git a/tests/agent-normalize.test.ts b/tests/agent-normalize.test.ts new file mode 100644 index 00000000..290bc08b --- /dev/null +++ b/tests/agent-normalize.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { createAgentFileLookup, resolveAgentSnapshotFile, type AgentFileSnapshot } from "../src/agent/normalize.js"; + +describe("agent normalize helpers", () => { + it("resolves snapshot files through a precomputed lookup", () => { + const root = path.resolve("agent-normalize-root"); + const included = path.join(root, "src", "included.ts"); + const omitted = path.join(root, "src", "omitted.ts"); + const snapshot: AgentFileSnapshot = { + root, + files: [included, omitted], + fileLookup: createAgentFileLookup([included]), + }; + + expect(resolveAgentSnapshotFile(snapshot, "src/included.ts")).toBe(included.replace(/\\/g, "/")); + expect(resolveAgentSnapshotFile(snapshot, "src/omitted.ts")).toBeNull(); + }); +}); diff --git a/tests/resolution.test.ts b/tests/resolution.test.ts index d6e5778c..6a51ea7a 100644 --- a/tests/resolution.test.ts +++ b/tests/resolution.test.ts @@ -10,6 +10,7 @@ import { resolveSpecifier, resolveWorkspacePackage, } from "../src/util.js"; +import { loadPhpComposerConfig } from "../src/util/resolution/phpComposer.js"; async function mkTmpDir(prefix: string): Promise { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -1600,12 +1601,14 @@ describe("Import Resolution", () => { ); const index = await buildProjectIndex(root); + const composerConfig = await loadPhpComposerConfig(path.join(root, "composer.json")); const result = await goToDefinition(index, { file: consumerFile.replace(/\\/g, "/"), line: 5, column: 16, }); + expect(composerConfig?.classmapExcludePrefixes).toContain(path.join(root, "src", "Excluded").replace(/\\/g, "/")); expect(result.status).toBe("not_found"); });