diff --git a/.claude/handoffs/cross-language-impact-coverage-2026-06-04.md b/.claude/handoffs/cross-language-impact-coverage-2026-06-04.md new file mode 100644 index 000000000..ba0f2ec3b --- /dev/null +++ b/.claude/handoffs/cross-language-impact-coverage-2026-06-04.md @@ -0,0 +1,316 @@ +--- +name: cross-language-impact-coverage-2026-06-04 +date: 2026-06-05 14:57 +project: codegraph +branch: feat/cross-language-impact-coverage +summary: Per-language coverage DONE (all 15 README langs + static-member pass + RN/Expo 95%+). Since then the live front is ROUTE FRAMEWORKS: import/aggregator style at ceiling (Express/FastAPI/Flask/requests/NestJS/Gin/Axum 95%+, Vapor/vapor-til 100%, SvelteKit 100% fair [component core + loader→page synthesizer], React Router 100% [config-based, no miss], Nuxt 93.5% fair [+ nested-component fix]; Axum 100% via a Rust self-relative submodule-call fix), convention/reflection + actor style at an honest sub-95% ceiling (ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%, actix 65.4% [actor message dispatch] — all measured). **SWEEP COMPLETE — every README framework measured;** Rocket lifted 62.5%→**93.8%** via a new `routes![]`/`catchers![]` macro extractor (only the crate-root lib.rs remains). Six engine improvements en route (3 Rust: self-relative + multi-segment `a::b::c()` module calls + Rocket route-macro extraction; 1 Swift: Fluent `@Siblings(through: Pivot.self)` metatype refs; 1 SvelteKit: `+page.server.js` `load`→`+page.svelte` synthesizer; 1 Nuxt: nested auto-imported ``→`media/Card.vue` resolution). The static-member-pass-for-TS/JS/Python lever was **measured & rejected** (e7b86df — 0 coverage gain, the import edge already covers it, + graph noise). **Then the 4 niche README languages were knocked out**: Lua 31.6%→**84.2%** + Luau 12.5%→**92.2%** (new require resolver, 4155609); Liquid 39.1%→**73.8%** (Shopify OS 2.0 JSON-template section parsing, 2f57119); Pascal 73%→**75.7%** (.dfm/.fmx form↔code-behind pairing, 2f30a3b) — so **ALL 22 README "Full support" languages + all 14 README frameworks now have coverage validation (full README parity).** Branch 41 commits ahead of main, 3 behind, not merged. +--- + +# Handoff: Cross-language impact/blast-radius coverage campaign + +## Resume here — read this first +**Current state:** Branch `feat/cross-language-impact-coverage` — now **41 commits ahead of `main`, 3 behind** (behind = 3 README-waitlist doc commits on main), tip `2f30a3b`, all pushed to `origin`=colbymchenry/codegraph. NOT merged (review branch). **FULL README PARITY: all 22 "Full support" languages + all 14 frameworks now have coverage validation.** Two arcs are done: (1) **per-language** cross-file coverage for ALL 15 README "Full support" langs + the static-member pass; (2) **cross-language RN/Expo** at 95%+ FAIR (gate hole fixed 082353e; same-dir include + KMP import 529d822). **Since the 2026-06-04 save a 12-commit ROUTE-FRAMEWORK front landed** (`61a993a`→`a3f59fb`) — see the "Route-framework headroom map" below; that map is the live edge. Working tree: clean except one unrelated untracked file (`assets/generate-waitlist.py`, README-waitlist tooling, not this campaign). Full suite green (**1187 passed | 2 skipped**, 59 files). +**Immediate next step:** **THE README SWEEP IS COMPLETE — all 22 "Full support" languages + all 14 frameworks have coverage validation.** Per-language DONE, RN/Expo DONE, route-framework + component-node front DONE, the 4 niche langs (Lua/Luau/Liquid/Pascal) DONE; **10 engine improvements** shipped this run (3 Rust + 1 Swift + 1 SvelteKit + 1 Nuxt + 1 Lua/Luau require resolver + 1 Liquid Shopify-JSON + 1 Delphi form pairing; the static-member-for-TS/JS/Python lever was measured & rejected). **Recommended: (A) ship it** — open the PR to `main` for the 41 commits; everything actionable is done. Optional follow-ups only: (B) build a convention/reflection lever if desired (Drupal `services.yml` DI, ASP.NET/Spring markup/reflection modeling) — each a large per-framework feature. **The convention/reflection frameworks (ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%, actix 65.4%) sit at a genuine sub-95% static-analysis ceiling — don't chase 95% there without large reflection/markup modeling (metric-gaming otherwise). The TS/JS/Python static-member pass is measured-&-rejected (e7b86df), don't re-try.** + +> Suggested next message: "Open the PR to main for the cross-language-impact-coverage branch (41 commits — full README parity: all 22 'Full support' languages + all 14 frameworks now have coverage validation)." + +## DONE: coverage bump to 95%+ (commit 529d822) — RN/Expo multi-platform repos +**Goal (from user):** bump async-storage (75.0%) and rn-device-info (72.4%) to 95%+. Two parts — real engine fixes + an honest fair-metric (the original 75/72 counted generated/build/config/entry files as if they were source). +**Engine fixes (real coverage, generalizable):** +1. **Same-dir C/C++ `#include`** — `#include "Foo.h"` had no directory awareness, so on a module with a same-named header per platform (windows/code/RNCAsyncStorage.h vs apple/) the includer landed on an arbitrary one (then the 082353e gate nulled the wrong-family match → real local header had 0 deps). Fixed C's quoted-include rule: resolve relative to the including file's OWN dir FIRST (`resolveViaImport` C/C++ branch in import-resolver.ts), plus a same-dir/proximity preference in `matchByFilePath`'s basename fallback (`pickClosestFileNode`). +2. **KMP commonMain import** — an `expect` decl + its `actual`s share one FQN across source sets; `resolveJvmImport` took `candidates[0]`, so one platform `actual` absorbed every common-side import and the `expect` looked unused. Now the same-FQN candidate CLOSEST to the importer (shared dir prefix, `expect` tiebreak) wins (`pickClosestJvmCandidate`). Both are the same "prefer the closest declaration on a name collision" principle as 082353e. +**Honest fair metric** (`/tmp/faircov.cjs`, prints every exclusion): denominator = authored source that *can* have an in-repo dependent. Excludes (per methodology, all auditable): structural (generated `.g.h`/codegen, `pch.*`, `*.gradle*`, `CMakeLists`, eslint/jest/babel config), see-through barrels (0 real symbols — web re-export files + umbrella/SDK headers ONLY; a 0-symbol *source impl* is counted as a real frontier zero, never hidden), and entry points (package `src/index`, platform `web`/`windows` entries, RN `ReactPackageProvider`). +**Before/after:** +| Repo | FAIR coverage before→after | residual zero (frontier) | +|---|---|---| +| async-storage | 75.0% → **97.4% (37/38)** | DatabaseFiles.kt (KMP expect-decl side, no in-repo caller) | +| rn-device-info | 72.4% → **95.2% (20/21)** | RNDeviceInfoCPP.cpp (`REACT_METHOD` macro methods not extracted) | +No regression (same metric, before→after): okhttp 75.9%→76.4%, kotlinx.coroutines 89.7% (neutral), leveldb 78.0% (neutral), redis 89.7%→89.9%, fmt 77.3% (neutral); cross-family false edges still 0 everywhere. 2 regression tests in `extraction.test.ts` ("Same-directory include + KMP import resolution"), both fail without the fix. Full suite 1169. + +## DONE: gate hole (commit 082353e) — cross-family references/imports +**Symptom (was):** in `react-native-async-storage`, a TS `type TestRunner` and a Kotlin `class TestRunner` collided — TS `references`/`imports` resolved onto the Kotlin class (web→jvm false match). Plus `import React`↔Swift `React` and a C++ `#include "RNCAsyncStorage.h"`↔iOS ObjC header (basename collision). +**Root cause:** the false edges came from the FRAMEWORK strategy — React's `resolveComponent` (frameworks/react.ts) name-matches `getNodesByName` with NO language check; its COMPONENT_KINDS includes `class`, so it returned the Kotlin `class` @0.8 (the TS `type_alias` filtered out), outranking the cross-lang-penalized (0.5) TS name-match. AND `imports` were never gated (only `references` was). NOTE: `this.frameworks` in resolveOne is NOT language-filtered per-ref (`getApplicableFrameworks` is unused there), so react.resolve runs for EVERY ref — its `languages` field is dead in that path. +**Fix:** new `crossesKnownFamily(a,b)` (both in a known family jvm/apple/web/c AND different) wired into `gateFrameworkLanguage` (NEW — gates the framework strategy, refs+imports), `gateLanguage` (extended to also gate `imports`), and `applyLanguageGate` (name-match candidate filter — re-points instead of dropping). KEY RULE (non-obvious): the `references` gate stays STRICT (`!sameLanguageFamily`); `imports` + the framework gate use the WEAKER both-known rule, so config↔code bridges (yaml/blade side not a known family) and `.vue`/`.svelte`→`.ts` imports survive. `calls` bridges are never gated. +**Before/after — precision fix (coverage HELD/up, false edges → 0):** +| Repo | FAIR coverage before→after | cross-known-family false refs/imports | +|---|---|---| +| async-storage | 75.0% (39/52) → **75.0% (39/52)** | **22 → 0** | +| rn-device-info (control) | 69.0% (20/29) → **72.4% (21/29)** | **5 → 0** | +Coverage held on async-storage (no recall lost) and ROSE on rn-device-info (re-pointing gave a real same-family file a correct dependent). Legit JS↔native `calls` bridges intact (rn-device-info: 91 JS→Java, 37 JS→ObjC, full Java↔ObjC↔C++ pairing). 2 regression tests in `extraction.test.ts` ("Cross-language type/import gate"), both fail without the fix. Full suite 1167. Measure: `/tmp/faircov.cjs ` (fair coverage + false-edge count) and `/tmp/xlang.cjs ` (cross-lang edges by src→tgt × kind). + +### Framework phase round 2 (commits d06a5ec, 74b599c, 2026-06-04) +(1) RCT_EXPORT_METHOD EXTRACTION (d06a5ec): RN bridge resolver now implements `extract()` for .m/.mm (added 'objc' to languages), reuses parseObjcRNExports to emit a method node per RCT_EXPORT_METHOD/REMAP (id `rn-export:`, named the JS-visible name). The macro parsed as ERROR before → iOS methods invisible. rn-device-info JS→objc 7→37, java↔objc pairs 22→29. (2) RN EVENT WRAPPER (74b599c): RN_NATIVE_SENDEVENT_RE catches `sendEvent(ctx,"X",body)` wrappers (inner `.emit` uses a variable) → native java/swift events now connect to JS hooks. Synth tag is `rn-event-channel`. VALIDATED async-storage (pairing + JS→native work; found the precision bug above). + +### Classic RN cross-platform pairing (commit 4a64ca5, 2026-06-04) +`rnCrossPlatformEdges` (callback-synth): a native method (java/kotlin/objc/cpp) with a JS-side `calls` edge = confirmed bridge method → link to same-norm-name native method in another language (`getFreeDiskStorage:`→`getFreeDiskStorage`, first selector keyword), both directions. Skip RN_INFRA names (addListener/getConstants/getName/…). rn-device-info: 152 pairs (Java↔ObjC↔C++). FOLLOW-UP: RCT_EXPORT_METHOD isn't a node (macro/ERROR parse) → only regular `- (void)` ObjC methods pair today. + +### Cross-language framework phase — round 1 (commit dbc4862, 2026-06-04) +NEW direction: RN/Expo repos where JS↔native crosses LANGUAGE boundaries. Existing bridge support is RICH (legacy NativeModules, TurboModule, Expo Modules extractor `expo-module:`-prefixed nodes, Fabric, rnEvents, swift-objc) — don't rebuild; validate + extend. Classic RN bridge WORKS (rn-device-info: 118 JS→Java + JS→ObjC calls). THREE Expo gaps fixed: (1) generic `AsyncFunction("x")` — regex didn't allow `<…>` so all Android Expo methods dropped; (2) cross-platform pairing — `expoCrossPlatformEdges` links Swift↔Kotlin impls of the same JS method (JS resolves to one platform only); (3) cross-lang type-ref precision — gated `references` edges to same language-family (name-matcher.ts `applyLanguageGate`/`sameLanguageFamily` + index.ts `gateLanguage`), so native `BatteryManager.EXTRA_LEVEL` doesn't falsely match a TS `BatteryManager`; framework resolvers NOT gated (keep config↔code bridges). Measure: `/tmp/xlang.cjs`. Detail in memory. + +### Objective-C result (commit 33ce431, 2026-06-04) +WORST README language at baseline. FOUR fixes (3 in tree-sitter.ts, 1 in name-matcher.ts): (1) SINGLE-ARG SELECTOR — `[c storeImage:k]` was named `storeImage` (no colon) at the call site, never matching `storeImage:`; add `:` when the message has a `:` token. (2) CLASS-MESSAGE RECEIVER REF — `[Foo sharedCache]`/`[[Foo alloc] init]` now emits a `references` edge to the capitalized class (covers the header). (3) #IMPORT BASENAME — `#import "Foo.h"` resolves to the header via matchByFilePath relaxed to accept bare filenames w/ short ext. (4) CLASS-METHOD COLON — `Foo.storeImage:` now resolves (broadened matchMethodCall method regex to allow colon selectors). AFNetworking 50%→**90%**, SDWebImage Core 33.8%→**91.6%**. GOTCHA: SDWebImage `include/SDWebImage/*.h` are SYMLINKS to `Core/` — measure Core/ only. Residual = public-API category methods called by app code (frontier). Detail in memory. + +### Dart result (commit 9487954, 2026-06-04) +Dart was in TYPE_ANNOTATION_LANGUAGES but produced ZERO `references` edges, AND mixins were dropped. (NOTE: dio raw 67.8% was example-dir pollution — real 86.4%.) Two gaps, gated `language==='dart'`: (1) MIXINS — `with` mixins live in a `mixins` CHILD of `superclass`; generic path read namedChild(0) as base + dropped mixins (and `class C with M` misread mixins as superclass). Dart branch in extractInheritance: `extends` base + `implements` per mixin. (2) METHOD TYPE REFS — `method_signature` wraps the real `function_signature` (params/return there) + return is a bare `type_identifier` not a `type` field. Dart branch in extractTypeAnnotations: descend to inner signature → extractTypeRefsFromSubtree. flutter/packages 88.8%→**92.4%**, dio 86.4%→**87.9%**. Residual = export barrels + platform-conditional files + enum-value access (`Enum.value` — value-read frontier; a Dart `Capitalized.member`→ref pass would be precise, the top follow-up). Detail in memory `impact-coverage-findings.md`. + +### Static-member / value-read pass (commit 857baf7, 2026-06-04) +The deferred cross-language lever, now DONE. A type used only via a static member / enum VALUE (`MediaKind.video`, `Colors.red`, `JsonScope.NAME`, `Foo::BAR`) recorded no edge (body walker only did CALLS + `new`). `extractStaticMemberRef` (tree-sitter.ts, in visitFunctionBody) emits a `references` edge to the CAPITALIZED receiver of a member-access value read (per-lang node in MEMBER_ACCESS_TYPES: field_access Java / member_access_expression C# / navigation_expression Kotlin+Swift / field_expression Scala / class_constant_access_expression+scoped_property_access_expression PHP / qualified_identifier C++; Dart = identifier + sibling value-read selector). Skips call callees; gated to STATIC_MEMBER_LANGS={java,csharp,kotlin,swift,scala,dart,php,cpp} — TS/JS/Python EXCLUDED (high coverage + retrieval-perf-sensitive). flutter/packages 92.4%→93.2%; additive elsewhere; nodes stable. Detail in memory. + +### C/C++ result (commit ec8fe3f, 2026-06-04) +C/C++ were already HIGH (name-matching resolves cross-file calls across the .h/.c split). NOT an import gap. The systematic gap was a C++ EXTRACTION BUG in languages/c-cpp.ts: `extractCppQualifiedMethodName`/`extractCppReceiverType` BFS'd the whole declarator INCLUDING `parameter_list` + `trailing_return_type` for a `qualified_identifier` → a free function `std::string TableFileName(const std::string& dbname)` was named **`string`** (from the param type), `auto f() -> std::string` named `string` (trailing return). Calls never resolved; defining file looked dependent-less. Fix: shared `findDeclaratorQualifiedId` skips `parameter_list` + `trailing_return_type`; plain names fall back to default extraction. leveldb 91.7%→**94.8%**, fmt 32 mis-named→1, redis (C, unaffected) 92.2% at ceiling. Residual = generated tables, macro-reached, function-pointer dispatch (`MAKE_CMD(...,sortCommand,...)` — deferred, broad/risky), C++ namespaces (deferred). Detail in memory `impact-coverage-findings.md`. + +### Ruby result (commits 44fb978 + 5bccab6, 2026-06-04) +TWO gaps. (1) MIXINS (44fb978): `include`/`extend`/`prepend Mod` parsed as a bare `call` to method `include` → ZERO edges. Fix in languages/ruby.ts visitNode: detect bare include/extend/prepend (guard `!receiver` so `arr.include?(x)` is safe) → emit `implements` edge class/module→module. (2) REQUIRE RESOLUTION (5bccab6, bigger than expected): `require "lib/foo"` → emit `imports` ref `lib/foo.rb` (load-path, suffix-matched by matchByFilePath); `require_relative "../foo"` → resolve vs requiring file's dir (`path.posix.normalize`); bare `require "json"` skipped. Resolves to the FILE node. **sidekiq 71%→76.8% (mixins)→100% (requires); activerecord 84.8%→93% (mixins)→96.8% (requires)** — Rails autoloads but still has explicit requires for sub-components. Residual = `constantize` class-string instantiation (associations/arel), generators, version files. Detail in memory `impact-coverage-findings.md`. + +### PHP result (commit acfb444, 2026-06-04) +ROOT CAUSE: PHP ignored NAMESPACES — every class qn was the bare simple name, so laravel's 7+ same-named `Factory` interfaces across namespaces collapsed to one arbitrary match, and `use` imports never resolved. Fixes (gated `language==='php'`): (1) **namespace capture** — `packageTypes:['namespace_definition']`+`extractPackage` in languages/php.ts → classes scoped to `Foo\Bar::Class`; (2) **use-import resolution** — `emitPhpUseRefs` emits an `imports` ref in `Foo\Bar::Baz` form, matched precisely by the resolver's `resolveQualifiedName` (THE big lever, 80.5%→94.9%); (3) **type-hint refs** — PHP-aware `extractPhpTypeRefs` (PHP types are `named_type`/`union_type` wrapping `name`, not `type_identifier`). guzzle 95.2%→**100%**, laravel 80.5%→**94.9%**. Residual = class-string/reflection wiring (service providers, facades, middleware) — genuine frontier. Detail in memory `impact-coverage-findings.md`. + +### Scala result (commit b5489d9, 2026-06-04) +Scala was the WORST starting point — extraction made nodes but almost NO edges for typeclass code (cats 1.66 edges/node). Not one gap but a family, all gated to `language==='scala'` in `extraction/tree-sitter.ts` (+ `languages/scala.ts`): (1) **parameterized extends** — `extends A[X] with B` packed all supertypes in one `extends_clause`; generic path took only namedChild(0) w/ full text `A[X]` so no typeclass matched → new shared `scalaBaseTypeName` unwraps `generic_type`, iterate all supertypes (cats 48.9%→77.2% from THIS alone); (2) **type refs** (Scala had ZERO `references`) — added scala to TYPE_ANNOTATION_LANGUAGES + walk EVERY curried `parameters` list (trailing `(implicit M: TC[A])`!) + `type_parameters` context bounds (`[A: Monoid]`) + val/var types from scala.ts (77.2%→89.2%); (3) **instantiation** `new T[...]` = `instance_expression`. cats 48.9%→**89.2% fair** (82.1% raw — scalafix/bench excluded), gatling 76.3%→**91.2%**. Residual = cross-build variants/laws/wildcard-barrels (frontiers). Detail in memory `impact-coverage-findings.md`. + +### Kotlin result (commit d8a2e91, 2026-06-04) +Systematic Kotlin gap = **Kotlin Multiplatform `expect`/`actual`** (the only Kotlin-unique construct). OkHttp (the README Kotlin benchmark) was ALREADY 96.2% out of the box; kotlinx.coroutines (KMP) was 76.8% → **93.5%**. Fix: new generic `extractModifiers` hook captures `expect`/`actual` (from `modifiers > platform_modifier`) onto the node's `decorators` list (wired once in `createNode`); `kotlinExpectActualEdges` in callback-synthesizer.ts links common decl → each platform `actual` as a heuristic `calls` edge (matched by qualified_name + the `actual` marker; decl side = non-`actual` same-qn node, which also gates out plain overloads; kind-widened so `expect class` ↔ `actual typealias` links). Node count stable. Residual = genuine frontiers (expect-decl sides, ServiceLoader/agent SPI, test infra). Full detail in memory `impact-coverage-findings.md`. + +## Goal +Make the engine's cross-file dependency graph complete for **every README "Full support" language**, so impact/`affected`/callers/callees/explore all see real dependencies. Definition of done per language: a real repo's symbol-bearing files mostly have correct dependents; residual is only genuine frontiers (no-symbol files, entry points, value-reads, macros). Each language: audit → fix → validate → commit to the branch. + +## Methodology (apply per language — this is the loop) +1. Clone 1 benchmark + 1 clean repo to `/tmp`. Index with `CodeGraph.initSync(repo,{config:{include:['**/*.'],exclude:[]}})` + `indexAll()` + `resolveReferences()` via a `node -e` against `dist/index.js`. +2. Measure **fair coverage** = % of *symbol-bearing* source files with ≥1 cross-file dependent. SQL: a file is a dependent target if it's the `target` of a non-`contains` edge whose `source` is in another file. **EXCLUDE from the denominator:** files with no non-`file` node (package-info.java, doc.rs, `__init__` umbrellas), tests, entry points (main/bin/examples/benches/fuzz/samples), and miscounted other-language files (e.g. `.kt` under a Java repo). +3. Audit the 0-dependent files → classify real-miss vs frontier. Controlled probe (2 tiny files) to isolate the exact gap. +4. Fix extraction/resolution. Re-measure. **Verify node count stays stable** (edges added, not nodes — except real new symbols like interface/record nodes). +5. `npm run build` (tsc must pass) → `npm test` (expect ~1151 passing) → add a test in `__tests__/extraction.test.ts` → CHANGELOG `[Unreleased] → Fixes` bullet. +6. `git add ` + commit (Co-Authored-By: Claude Opus 4.8 (1M context) ) + `git push origin feat/cross-language-impact-coverage`. Do NOT commit `.claude/handoffs/*`. + +## Key findings — the recurring gap shapes & where they're fixed +- **Foundation (16b5633):** `imports` edges are same-file (file→local import node), so the old `getFileDependents` returned 0 for every file. Added `getDependentFilePaths`/`getDependencyFilePaths` in `src/db/queries.ts` (indexed JOIN, all kinds except `contains`); `src/graph/queries.ts` delegates. +- **Import/binding linking** (per-lang emit* in `src/extraction/tree-sitter.ts`): `emitImportBindingRefs` (TS/JS named/default/namespace), `emitReExportRefs` (TS `export {X} from`), `emitPyFromImportRefs` (Python `from m import X`), `emitRustUseBindingRefs` (Rust `use`/`pub use`, emits FULL path). All gated by language in `extractImport`. +- **Module-path resolution** (`src/resolution/import-resolver.ts`): `resolvePythonModuleMember` + `resolveModuleImportToFile` (Python+TS namespace), `resolveGoCrossPackageReference` (Go, pre-existing), `resolveRustPathReference`+`resolveRustModuleFile`+`rustCrateRootDir`/`rustSelfModuleDir` (Rust `crate::`/`self::`/`super::`). Resolve a path's module PREFIX to a file, find the leaf there — fixes common-name collisions. +- **Instantiation:** `INSTANTIATION_KINDS` in tree-sitter.ts now includes `composite_literal` (Go) + `struct_expression` (Rust). `extractInstantiation` keeps the package qualifier for Go (cross-pkg resolve); strips for others. Also normalizes parenthesized type conversions `(*T)(x)`. +- **Interface/trait dispatch (#584):** `IFACE_OVERRIDE_LANGS` in `src/resolution/callback-synthesizer.ts` now includes `go` and `rust`. Needs the interface/trait's METHODS extracted: Go via `extractGoInterfaceMethods` (tree-sitter.ts), Rust via adding `function_signature_item` to rust.ts function/methodTypes. `goImplementsEdges` synthesizes Go implicit `implements` edges (method-set match) and must `insertEdges` FIRST in `synthesizeCallbackEdges`. +- **Annotations / attributes / property wrappers (UNIFIED via `extractDecoratorsFor`):** it now (a) descends into `modifiers` nodes (Java/Kotlin/C#), (b) recognizes Swift `attribute` + `user_type`. Java needed `annotation_type_declaration` added to `interfaceTypes` (java.ts). C# needed `record_declaration`/`record_struct_declaration` (csharp.ts). Swift needed a dispatcher branch running `extractDecoratorsFor`+`extractVariableTypeAnnotation` on `property_declaration` inside a type (Swift instance props aren't nodes). +- **In-body type annotations (TS):** `visitFunctionBody` now extracts `variable_declarator` type annotations (`const x: Foo`). + +## Per-language results — file-dependent coverage (% of symbol-bearing source files with ≥1 cross-file dependent) + +| Language | Repo | Before | After | Key fix | +|---|---|---|---|---| +| TypeScript/JS | codegraph (this repo) | 62.5% | **95.8%** | import + re-export + namespace linking; in-body type annotations | +| Python | requests | 54.1% | **100.0%** | `from x import` linking; `from . import sub` + `sub.f()` module-member resolution; relative-dot path fix | +| Python | flask (src) | 66.7% | **87.5%** (true ceiling — residual all correct-0) | (same) | +| Go | gin | 62.7% | **96.6%** | composite literals → instantiates; package-level var registries; `(*T)(x)` conversions; implicit interface satisfaction (#584) | +| C# | MediatR (library) | 81.5% | **85.2%** | `record` / `record struct` indexed (#237) | +| Rust | ripgrep | 63.4% | **86.7%** | struct literals; trait dispatch (trait methods + #584); `use`/`pub use` linking; module-path resolution for `pub use self::x::y` | +| Rust | tokio (src) | 70.0% | **81.9%** | (same — number is honest/precise; earlier leaf-only match had inflated it) | +| Java | gson | 78.2% | **85.1%** (raw) · **93.3% fair** | annotations: index `@interface` defs + link `@Foo` usages (in `modifiers`) | +| Java | retrofit | 80.5% (raw) | **94.9% fair** | (same) | +| Swift | Alamofire | 93.0% | **95.3%** | property wrappers / attributes (`@Argument`/`@Published`/`@objc`) | +| Swift | swift-argument-parser | 84.6% | **96.2%** | (same) | +| Kotlin | OkHttp | 96.2% | **96.2%** | already at ceiling (JVM, barely uses KMP) — no change needed | +| Kotlin | kotlinx.coroutines | 76.8% | **93.5%** | Kotlin Multiplatform `expect`/`actual` linking (incl. `actual typealias`) | +| Scala | typelevel/cats | 48.9% | **89.2% fair** (82.1% raw) | parameterized extends + type refs (implicit/context-bound) + `new` | +| Scala | gatling | 76.3% | **91.2%** | (same) | +| PHP | guzzle | 95.2% | **100.0%** | namespace capture + `use`-import resolution | +| PHP | laravel/framework | 80.5% | **94.9%** | namespace capture (disambiguates same-named contracts) + use-imports + type-hints | +| Ruby | rails/activerecord | 84.8% | **96.8%** | mixin edges (`include`/`extend`/`prepend`) + require resolution | +| Ruby | sidekiq | 71.0% | **100.0%** | mixins + `require`/`require_relative` → file resolution | +| C++ | google/leveldb | 91.7% | **94.8%** | fix free-function name extraction (was named after param/return type) | +| C | redis | 92.2% | **92.2%** | already at ceiling (C unaffected; residual = generated/macro/fn-ptr) | +| Dart | flutter/packages | 88.8% | **92.4%** | `with` mixins + method type references | +| Dart | dio | 86.4% | **87.9%** | (same; raw 67.8% was example-dir pollution) | +| Obj-C | AFNetworking | 50.0% | **90.0%** | single-arg selectors + class-receiver refs + #import + class-method resolution | +| Obj-C | SDWebImage (Core) | 33.8% | **91.6%** | (same; `include/` dirs are symlink dups — measure Core/) | +| Lua | nvim-telescope | 31.6% | **84.2%** | `require()` module resolution: dotted `a.b.c`→`a/b/c.lua` + instance-path leaf, path-suffix match w/ same-dir preference (4155609). Residual = telescope's dynamic `setmetatable` lazy-require pickers (frontier). | +| Luau | dphfox/Fusion | 12.5% | **92.2%** | (same require resolver — `require(script.Memory.deriveScope)`) | +| Liquid | Shopify/dawn | 39.1% | **73.8%** | Shopify OS 2.0 JSON-template section parsing: index `templates/*.json` + section-group `sections/*.json` (incl. nested `templates/customers/`), link each `"type"`→`sections/X.liquid` (2f57119). Snippet `{% render %}` already worked. Residual = preset/theme-editor sections (no static ref) + dynamic `{% render block %}`. | +| Pascal/Delphi | PascalCoin | 73.0% | **75.7%** | `uses`-resolution ALREADY worked (73%); + `.dfm`/`.fmx` form ↔ same-basename `.pas` code-behind pairing (2f30a3b). Residual = vendored third-party libs (`src/libraries/*` internal units, ≈node_modules) + dynamic form/RPC instantiation. | + +**"raw" vs "fair":** "fair" excludes files that *structurally can't* have dependents (no-symbol files like `package-info.java`/doc-only, entry points, tests, and other-language files miscounted by the include glob). For Java the raw numbers were heavily polluted (gson had many `package-info.java`; retrofit had `.kt` + samples), so the fair number is the real one (~93–95%). The other languages' numbers above are already on symbol-bearing source files (effectively "fair"). C# MediatR's 85.2% is the library-only figure; a package-info-excluded "fair" wasn't separately computed but is higher. + +## Per-framework results — cross-language file-dependent coverage (RN/Expo, multi-platform JS↔native) + +| Framework | Repo | Before | After | Key fix | +|---|---|---|---|---| +| React Native / Expo | react-native-async-storage | 75.0% | **97.4% fair** (37/38) | cross-family gate (082353e) + same-dir C/C++ `#include` + KMP commonMain import (529d822) | +| React Native | react-native-device-info | 72.4% | **95.2% fair** (20/21) | cross-family gate (082353e) + honest fair metric (its 529d822 engine-fix targets are excluded entry files) | + +**Metric note (read before trusting the "Before"):** the "Before" 75.0%/72.4% used an **under-exclusive** denominator — it counted generated codegen (`.g.h`), build scripts (`pch.*`, `*.gradle.kts`), tooling config (eslint/jest/yarn), and platform/registration **entry points** as if they were source. The "After" uses the **honest fair metric** the per-language table uses: excl. structural (generated/build/config/test), see-through barrels (web re-export files + umbrella/SDK headers — but NOT a 0-symbol source impl, which is a real frontier), and entry points (package `src/index`, platform `web`/`windows` entries, RN `ReactPackageProvider`). **Apples-to-apples** (fair metric held constant, isolating just the 529d822 engine fixes): async-storage **92.1% → 97.4%** (+RNCAsyncStorage.h via same-dir include, +Platform.kt via KMP import); rn-device-info **95.2% → 95.2%** (neutral — its same-dir/KMP targets are excluded entry headers, so its lift to 95.2% was the metric correction + the 082353e gate). Residual zeros (real frontiers): async-storage `DatabaseFiles.kt` (KMP `expect`-decl side, no in-repo caller); rn-device-info `RNDeviceInfoCPP.cpp` (`REACT_METHOD` macro methods not extracted). Measure with `/tmp/faircov.cjs --list`. No regression on controls: okhttp 75.9→76.4, kotlinx.coroutines 89.7 (neutral), leveldb 78.0 (neutral), redis 89.7→89.9, fmt 77.3 (neutral); cross-family false edges 0 everywhere. + +## Route-framework headroom map (canonical app per README framework, FAIR coverage) + +Measured 2026-06-04 (commit 61a993a) on a canonical real app for each README route framework. This is the active front of the campaign — the unmeasured frameworks have the real headroom. + +| Framework | App repo | FAIR coverage | Status / next | +|---|---|---|---| +| Express (TS) | express-realworld | 70.4% → **100%** ✅ | DONE (2a0b6e0): renamed default-import → module file (route controllers `export default router`). | +| FastAPI (Py) | fastapi-realworld | 78.6% → **98.0%** ✅ | DONE (2835623): source-aware `from pkg import submodule` (router aggregator). 1 residual = aliased sub-aggregator. | +| Flask (Py) | flask (lib) | **100.0%** ✅ | DONE (entries/barrels excluded) | +| requests (Py) | requests (lib) | **100.0%** ✅ | DONE | +| NestJS (TS) | nestjs-realworld | 93.8% → **96.8%** ✅ | DONE (main.ts entry excluded) | +| Gin (Go) | gin (lib) | **96.5%** ✅ | DONE (faircov Go `_test.go` exclusion) | +| Laravel (PHP) | laravel (lib) | 92.0% | done (per-language; app not separately measured) | +| Rails (Ruby) | rails (lib) | 89.6% | done (per-language) | +| Django (Py) | django-realworld | 45.9% → **74.1%** | PARTIAL (58dc463): abs-module-import + `include('app.urls')` done. Ceiling ~83% w/ entries excluded. FRONTIERS: signals via in-body `ready(): import myapp.signals` (Python in-body imports NOT extracted — visitFunctionBody walks calls but not import_statement); DRF/string-config exception classes (`EXCEPTION_HANDLER: '...'`). | +| ASP.NET (C#) | eShopOnWeb | 59.3% → **83.9%** | chained extension calls (4c14413) + framework-entry exclusions (b) + Razor/Blazor markup parser (59b8de2 tags/@model + 90c5f39 @code) + **C# namespaces (dc7d033) + Razor `@using` disambiguation (9e5a951)** — DTOs now resolve to `BlazorShared.Models::CatalogBrand` not the same-named entity. C# constructor DI / interface→impl ALREADY worked. Residual ~24 = reflection/proxy (AutoMapper profiles / Swagger filters / middleware / health checks — invoked by reflection, a separate modeling feature) + a few C# static-const reads (`Constants.X` — extend the static-member pass to C#). | +| Spring (Java) | spring-petclinic | 65.2% → **83.3%** ✅ | DONE — convention/reflection ceiling, on par with ASP.NET. 30 main java = 15 covered + 7 entry (1 `*Application` main + 6 `*Controller`) + 5 barrel (`package-info.java`) + 3 zero. 3 residual zeros all reflection-registered: `CacheConfiguration`/`WebConfiguration` (`@Configuration`, component-scanned — `WebConfiguration` is even `@SuppressWarnings("unused")`) + `PetClinicRuntimeHints` (`@ImportRuntimeHints(X.class)` class-literal = the documented `Foo.class`→`Class` frontier). faircov fix = barrel-exclude `package-info.java`/`module-info.java` shells. **No `samples`-dir exclusion ever existed in the script** — that handoff note was inaccurate. | +| Axum (Rust) | realworld-axum-sqlx | 72.7% → **100%** ✅ | DONE (a3f59fb) — import/aggregator style, confirmed in Rust. REAL engine miss found+fixed: bare Rust `submodule::fn()` calls (the `X::router()` router-assembly pattern) resolved crate-relative only → now self-relative FIRST (current module), then crate fallback. + `main.rs`/`src/bin/*.rs` faircov entry exclusion. Clean A/B (same faircov, engine ±fix): axum +2 → 100%, ripgrep neutral (66/79), tokio +3 (260/321); cross-family false edges 0. Regression test fails without the fix. | +| Rocket (Rust) | TatriX/realworld-rust-rocket | 62.5% → 68.8% → **93.8%** ✅ | DONE — TWO engine wins. (1) 7bb958b: a 3-segment `database::profiles::find()` call (inside `db.run(\|c\| …)`) was dropped by the resolver PRE-FILTER (leaf never checked) → +database/profiles.rs. (2) 6d214cd: NEW `routes![]`/`catchers![]` macro extractor — the handler paths live in a raw token-tree, so the 4 `routes/*.rs` handler modules (mounted at runtime) looked uncalled; now reconstructed from the macro + resolved via the Rust path resolver → all 4 covered. Only `lib.rs` (crate-root/launch hub, see-through) remains. No control regression; false edges 0; nodes unchanged (edges only). | +| actix-web (Rust) | fairingrey/actix-realworld-example-app | **65.4%** | DONE (genuine ceiling). actix **1.x ACTOR style**: routing resolves fine (`.route(web::post().to(users::register))` → handlers covered), but the DB layer is reached via actor message dispatch (`db.send(RegisterUser)` → `impl Handler for DbExecutor`) — a DYNAMIC runtime boundary, no static edge (the actix analog of the MediatR/reactive runtimes the engine deliberately leaves uncovered). 9 zeros = 5 `db/*.rs` (actor dispatch) + `models/mod`/`utils/mod`/`prelude` (glob `pub use self::x::*` re-export roots) + `utils/hasher` (reached only via that glob). Bridging needs an actor-message synthesizer (dynamic-dispatch feature), not a static-resolution fix. | +| Vapor (Swift) | vapor-til (canonical) | 85.7% → **100%** ✅ | DONE — import/aggregator confirmed in Swift. Controllers covered via `app.register(collection: X())`, models via controller queries + migrations. REAL miss found+fixed (bb7659e): the many-to-many pivot model was referenced ONLY via `@Siblings(through: Pivot.self)` — a metatype arg inside a property-wrapper attribute that wasn't walked → now routed through `extractStaticMemberRef` → pivot covered. + faircov excl `Package.swift` (SPM manifest) & `Public/` (static assets). A/B (same faircov): vapor-til +1 → 100%, Alamofire neutral (44/50); false edges 0. | +| Vapor (Swift) | penny-bot (real prod, 166 files) | **73.2%** | SECONDARY — a serverless/DI architecture, NOT the canonical route→controller→model shape. 37 zeros = AWS Lambda handlers (`*Handler`/`*Lambda`, invoked by the AWS runtime → entry points, no in-repo caller) + `exports.swift` re-export barrels (see-through, like Rust mod.rs roots) + Swift extension files (`+X.swift`) + DTOs/services reached via DI / JSON-decoding (genuine frontiers). Excluding the Lambda entries + exports barrels (faircov gaps) → ~80%; genuine residual is the DI/serialization architecture. NOT chased — vapor-til is the canonical result. | +| SvelteKit (Svelte) | sveltejs/realworld (official) | 35.9% raw → **100% fair** ✅ | DONE — characterization, NO engine miss (first framework that needed none). The `.svelte` import resolution ALREADY connects the whole component graph (page→component→lib: ArticlePreview←ArticleList, Nav←+layout, ListErrors←4 pages — imports+references+calls all resolve). Low RAW = the file-convention shape: 25/39 files are SvelteKit convention ENTRIES (`+page`/`+layout`/`+error`/`+server`/`hooks` — loaded by the framework BY PATH, no in-repo caller = route leaves, like route handlers). Excluding those (new faircov ENTRY patterns) → 14/14 = 100% of the coverable core. ONE framework-mediated gap: `+page.server.js` `load` → `+page.svelte` `data` is NOT a static link (runtime `data` prop) — was bridged with a SvelteKit convention synthesizer (sibling files by path, like Rocket routes!) — impact-granularity, not coverage (both files are entries). **BUILT (3ea03e5): `svelteKitLoadEdges`** links each page component → its OWN-dir loader's `load`/`actions` (same for `+layout`); path-deterministic (19 links / **0 cross-dir mislinks** on realworld), provenance heuristic, nodes unchanged. `getImpactRadius(load)` now surfaces its page. | +| React Router (React) | gothinkster realworld | **100%** ✅ | DONE — characterization, NO engine miss. The CONFIG-based component-node sub-shape: route components are NAMED in source (`` → App.js imports + references Login), so they resolve cleanly — verified Login/Editor/Profile all covered BY App.js's route config (imports+references). 29/29 (only `index.js` CRA entry + 8 anonymous-default-export reducers [0 named symbols → barrels] excluded). CONTRAST with SvelteKit (file-convention, needs entry-exclusion): the component-node category has TWO sub-shapes, both handled by the engine as-is. (App is old v4/v5 `component={X}`; modern v6/v7 `element={}` + data-router `{element, loader}` are also source-referenced → same conclusion.) No commit (nothing to fix). | +| Nuxt (Vue) | nuxt/movies (official demo) | 47.6% → **93.5% fair** ✅ | DONE — file-convention component framework (like SvelteKit) but with an AUTO-IMPORT twist that was a REAL miss (1dec765). Nuxt auto-imports a NESTED component by a DIR-PREFIXED name: `components/media/Card.vue` → ``, but the node is named `Card` → the PascalCase template usage never resolved (the synth only matched kebab tags; the extractor's PascalCase ref name-matched `MediaCard`→nothing). Fix in `vueTemplateEdges`: match PascalCase tags + a `nuxtComponentName` map (`media/Card.vue`→`MediaCard`, incl. Nuxt dir de-dup) → +9 nested components. + faircov Nuxt entry exclusions (pages/, app.vue, error.vue, layouts/, plugins/, middleware/, server/). 2 residual zeros = genuine frontiers (unused `` variant + unimported `constants/images.ts`). Composables auto-import (`useTmdb()`) already resolved by name-match. false edges 0, nodes unchanged. **vs SvelteKit:** SvelteKit needed NO component fix (svelte imports resolve); Nuxt DID (its auto-import dir-prefix naming is unique). | +| Drupal (PHP) | token (contrib module) | 57.1% raw → **78.9% fair** | DONE — the MOST reflection-heavy framework, a genuine sub-95% ceiling (≈ASP.NET 83.9%, Django 74.1%). Static parts WORK: `*.routing.yml`→controller (drupal.ts resolves it — `TokenTreeController` covered via the route ref), PHP class refs (namespaces/`use`/type-hints). 4 residual zeros all DI/reflection-wired: 2 services in `services.yml` (`TreeBuilder` = `@token.tree_builder`, `TokenFieldRender`), a DYNAMICALLY-routed controller (`TokenDevelController` via `RouteSubscriber`, not static YAML), a field-plugin class (`MenuLinkFieldItemList` list_class). + faircov excl JS libraries + convention entries (`.install`/`.module`, `src/Plugin`/`Element`, Drush, `*ServiceProvider`, `Routing`/`*Subscriber`). LEVERS (drupal.ts TODOs, large per-mechanism features, NOT chased): model `services.yml` DI (→ covers the 2 services), plugin annotations + modern `#[Hook]` attributes. No commit (no simple miss). | + +**RESULT: all import/aggregator-style frameworks are at 95%+** (Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%, Axum 100%). + +**Option (b) DONE (metric-only — framework-entry exclusions in `/tmp/faircov.cjs`):** added convention-entry patterns (`*Controller.cs/java`, `*.cshtml.cs`, `*Endpoint.cs`, EF `Data/Config/*.cs`, `Program/Startup.cs`, `*Application.java`, Django `admin.py`/`apps.py`). Result — convention frameworks rise but **still cap well below 95%**: ASP.NET 65.3% → **77.2%** (50 entries excluded), Spring petclinic 65.2% → **83.3%** (after the `package-info.java` shell-exclusion fix below), Django **74.1%**. The import-style frameworks are unaffected (Express 100%, FastAPI 98%, NestJS 96.8%, Gin 96.5% — the C#/Java/Django entry patterns don't touch them). + +**WHY (b) doesn't reach 95% — the honest ceiling:** after excluding routed/reflection-registered entries, the residual zeros are **markup-driven** code-behind (Blazor `.razor` / Razor `.cshtml` / Thymeleaf reference the `.cs`/`.java`, but the markup isn't parsed → ViewModels, DTOs, components look unused) and **reflection/proxy** code (Spring Data repository proxies, AutoMapper profiles, Swagger filters, DI/middleware registration, Django signals/string-config). These are genuine static-analysis frontiers — reaching 95% needs (1) parsing template markup to link markup→code, or (2) per-framework reflection/proxy modeling — both large features. **Excluding markup-driven business code (DTOs/ViewModels) from the metric to fake 95% would be gaming — NOT done.** Note: business LOGIC (services, repos) IS covered in all three; the residual is leaf views/DTOs/configs whose impact is captured the other direction (route→handler). + +**Generalizable engine fixes shipped this campaign (all benefit beyond their trigger framework):** Python absolute `import a.b.c` (61a993a); source-aware `from pkg import submodule` (2835623); Django `include('app.urls')` claim (58dc463); chained method calls `a.b.Method()` incl. C# extension methods (4c14413); renamed default-import → module file (2a0b6e0); Rust bare `submodule::fn()` calls resolved self-relative — current module first, then crate fallback (a3f59fb); multi-segment Rust `a::b::c()` module calls no longer dropped by the resolver pre-filter — the leaf name is now checked (7bb958b). + +**KEY REALITY (honest):** apps dominated by **convention/reflection-driven** code (ASP.NET MVC/Razor/Blazor, EF config, reflection DI; Django signals/DRF; any framework whose handlers are discovered by routing/DI container, not called by in-repo code) have files with NO static in-repo caller. Those are genuine static-analysis frontiers — **literal 95% is not reachable** on such apps without either (a) excluding all framework-entry conventions from the fair denominator (defensible per methodology but extensive + per-framework), or (b) modeling each framework's convention routing + DI container (large per-framework engine work). The DI-heavy/convention-heavy frameworks (ASP.NET, Spring, MVC) are this category; the import/aggregator-style ones (FastAPI, Flask, Express, Gin) reach 95%+ with tractable resolution fixes. + +**SWEEP COMPLETE — every README framework measured.** Route frameworks: Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%, Axum 100%, Rocket 93.8%, Vapor 100%, Laravel 92%, Rails 89.6% (import/aggregator) · ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Django 74.1%, actix 65.4% (convention/reflection/actor ceiling). Component-node: React Router 100% (config-based) · SvelteKit 100% fair, Nuxt 93.5% fair (file-convention). **Component-node category fully characterized**: config-based (route components named in source → 100% raw) + file-convention (discovered by path → entry-exclusion; SvelteKit needed NO component fix, Nuxt needed the dir-prefix auto-import fix). Convention/reflection frameworks (ASP.NET/Spring/Drupal/Django/actix) are at a genuine sub-95% static-analysis ceiling — the levers are large per-mechanism features (markup/reflection/DI modeling), deliberately not chased. **faircov exclusions added this session:** language-aware test files (`_test.go`, `test_*.py`, `*Tests.cs`, `*_spec.rb`, …); generated migrations (Django/Alembic `migrations/`, EF `Migrations/*.cs`/`*.Designer.cs`/`*ModelSnapshot.cs`); Python entries (`__main__.py`, `setup.py`, `conf.py`, `docs/`) + `__init__.py` barrels. faircov now barrel-excludes `package-info.java`/`module-info.java` shells (package-declaration-only files — they carry a `namespace` node so `realSyms !== 0`, but nothing can import them, so they can never be a dependency target; the Java analog of `__init__.py`). This is what actually blocked an honest Spring number: petclinic's 5 `package-info.java` sat in the denominator as zeros, deflating 83.3% → 65.2%. **There was never a `samples`-dir exclusion in the script** — that earlier handoff note was inaccurate (the only similar rule is `example[s]?`, which can't match `samples`). Route-framework-front faircov ENTRY additions: Rust binary entries (`main.rs`, `src/bin/*.rs`); Swift `Package.swift` (SPM manifest) + `Public/` static assets; SvelteKit file-convention entries (`+page`/`+layout`/`+error`/`+server.*`, `hooks.*`, `service-worker.*` — framework-discovered route leaves, no in-repo caller); Nuxt file-convention entries (`pages/`, `app.vue`/`error.vue`, `layouts/`, `plugins/`, `middleware/`, `server/`, `proxy/routes/`); Drupal convention entries (`.install`/`.module`/`.profile`/`.theme` hook files, `src/Plugin`/`src/Element` annotation-plugins, `src/Drush`, `*ServiceProvider.php`, `src/Routing`/`*Subscriber.php`) + `js/` library assets. + +## How to push each language higher (remaining levers) + +**The one big cross-language lever: a static-member / const value-read pass.** Extract `Type.MEMBER` (capitalized/known-type receiver) as a `references` edge to `Type`. This is the universal deferred data-flow frontier and would lift **C#, Java, Swift, TS, and Rust at once**. Implement once in extraction with a heuristic (receiver resolves to / looks like a type → emit ref; skip lowercase `obj.field`). Trade-off = some instance-field-access noise; that's why it's been deferred. This is the highest-leverage single task remaining. + +Per language — what's left and the action to improve it: +- **C# — MediatR 85.2% (raw, the lowest real number):** + - *raw→fair:* exclude no-symbol files (`TypeForwardings.cs` = assembly attrs only, `package-info`-equivalents) + benchmark `main`s → ~92%+. **A fair re-measure was never run for C# — do it first; the "real" number is materially higher.** + - *to improve further:* static/const value reads (`BuildInfo.BuildDate`, enum `Edition?` where a same-named property shadows the type) → the static-member pass. +- **Java — gson 85.1% raw → 93.3% fair:** + - *raw→fair:* exclude `package-info.java` (no symbols) + `.kt`/samples (already done for the fair number). + - *to improve fair further:* static-field reads (`X.FACTORY`), `Foo.class` class literals (currently `Foo.class` references `Class`, not `Foo`), constant reads (`JsonScope.X`) → the static-member pass. +- **Rust — tokio 81.9% (lowest of the high group), ripgrep 86.7%:** + - residual = see-through `mod.rs`/`lib.rs` roots (correct-0), **macro-reached code** (`log!`, custom `macro_rules!`, derives — the big Rust frontier, hard), external-trait-only impls. + - *to improve:* macro handling (large, separate project) + static/const reads. Note tokio's 81.9% is already honest/precise (path resolution removed spurious leaf-match edges). +- **Go — gin 96.6%:** + - residual = `//go:build` alternates (appengine/jsoniter/go_json/sonic/nomsgpack) + external-API `version.go`. + - *to improve:* a **build-constraint parser** (evaluate `//go:build`) so inactive variants are excluded from the denominator or all variants are linked (recall-first). Only matters for build-tag-heavy repos; niche. +- **TS 95.8% · Python requests 100% / flask 87.5% · Swift 95.3% / 96.2%:** at/near true ceiling — residual is entry points, see-through barrels, external public API, and value-reads. The **only** lever left for these is the static-member/const pass. + +**Bottom line:** Python/TS/Swift/Go are effectively at ceiling. The two with real headroom are **C#** (mostly a fair-remeasure — do that first) and **Rust tokio** (macros — hard). The static-member/const pass is the one change that moves *everything* a few points; the rest is per-language frontier work. + +## Which tools benefit (asked + answered this session) +It's a GRAPH-WIDE update (one shared `edges` table). `getCallers`/`getCallees` follow `['calls','references','imports']`; `getImpactRadius` + `getFileDependents`/`affected` follow **all except `contains`**; `codegraph_explore` composes all of them. So `instantiates`/`implements`/`decorates` edges show in impact+explore but **not** callers/callees (a pre-existing edge-kind filter in `getCallers`/`getCallees` — could be broadened, deferred). + +## Gotchas +- **Include globs don't filter reliably** — tests/examples/benches/`.kt` leak into the index. Filter in the measurement SQL, not the config. +- **`/tmp` clones persist across turns** — `rm -rf /.codegraph` before re-indexing or `initSync` throws "already initialized" and you measure a STALE index (this bit me ~3×; a stale index massively under-reports). +- **Fair metric must exclude no-symbol files** (package-info, doc-only) — they can't have dependents; counting them is dishonest-low. Also a slightly-LOWER honest number (Rust tokio 83→82 after path resolution) beat the spurious-inflated one — precision over optics. +- **Build vs test:** `npm test` uses esbuild (no typecheck); `npm run build` (tsc) is what catches type errors. Always build before committing. Strict null on regex groups bit me — avoid `m[1]` indexed access. +- Node-version regex-group access (`m[1]`) is `string|undefined`; use guards. + +## How to test & validate +- `npm run build` → tsc clean (must pass before commit). +- `npm test` → **1178 passed | 2 skipped** (59 files, verified 2026-06-05). Per-language tests live in `__tests__/extraction.test.ts` (describe per language); route-framework/RN tests in `__tests__/{react-native-bridge,expo-modules,rn-event-channel}.test.ts`. +- Coverage probe recipe: clone repo → `node -e "...initSync...indexAll...resolveReferences..."` → the fair-coverage SQL (see Methodology #2). Node count stable = no explosion. +- Full per-language findings + exact fixes: memory file `~/.claude/projects/-Users-colby-Development-CodeGraph-codegraph/memory/impact-coverage-findings.md`. + +## Repo state +- branch `feat/cross-language-impact-coverage`, last commit `2f30a3b feat(impact): pair Delphi forms with their .pas code-behind (.dfm/.fmx ↔ .pas)`. +- **41 commits ahead of `main`, 3 behind** (behind = 3 README-waitlist doc commits on main). All pushed to origin. NOT merged — branch is for review. Niche-lang commits 39–41: 4155609 (Lua/Luau require resolver), 2f57119 (Liquid Shopify-JSON sections), 2f30a3b (Delphi .dfm form pairing). +- Commits 1–20 (per-language + RN/Expo, `16b5633`→`529d822`): 16b5633 (foundation+TS/Py/Go/C#), b538aee + 2ac7df5 (Rust), badb124 (Java), d111f26 (Swift), d8a2e91 (Kotlin), b5489d9 (Scala), acfb444 (PHP), 44fb978 + 5bccab6 (Ruby), ec8fe3f (C/C++), 9487954 (Dart), 857baf7 (static-member pass), 33ce431 (Objective-C), dbc4862 (Expo bridges), 4a64ca5 (classic RN pairing), d06a5ec (RCT_EXPORT_METHOD nodes), 74b599c (RN event wrapper), 082353e (cross-family gate), 529d822 (same-dir include + KMP import). +- Commits 21–31 (ROUTE-FRAMEWORK front, `61a993a`→`b653688`, added after the original save): 61a993a (Python absolute `import a.b.c`), 2835623 (source-aware `from pkg import submodule` — FastAPI), 58dc463 (Django `include('app.urls')`), 4c14413 (chained `a.b.Method()` + C# extension methods), 2a0b6e0 (renamed default-import → module file — Express), 59b8de2 (Razor/Blazor `.cshtml`/`.razor` markup parser), 90c5f39 (Blazor `@code`/Razor `@{}` → C# extractor), 4589cf9 (docs), dc7d033 (C# namespace extraction), 9e5a951 (Razor `@using` disambiguation), b653688 (handoff notes + docs). Commit 32 (`a3f59fb`): Rust self-relative `submodule::fn()` resolution — Axum realworld 72.7%→100%, controls neutral/+3, regression test added. Commit 33 (`7bb958b`): multi-segment Rust `a::b::c()` calls no longer dropped by the resolver pre-filter (leaf-name check in `hasAnyPossibleMatch`) — Rocket realworld 62.5%→68.8%, additive elsewhere, regression test added. Commit 34 (`6d214cd`): Rocket `routes![]`/`catchers![]` macro extractor (parse the handler paths out of the raw token-tree, emit references) — Rocket 68.8%→93.8%, nodes unchanged (edges only), no control regression, regression test added. Commit 35 (`bb7659e`): Swift property-wrapper attribute-arg type refs — route a property's attribute args through `extractStaticMemberRef` so a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot — vapor-til 94.7%→100%, Alamofire neutral, regression test added. Commit 36 (`3ea03e5`): SvelteKit `load`→page synthesizer (`svelteKitLoadEdges` in callback-synthesizer.ts) — links each `+page.svelte`/`+layout.svelte` to its OWN-dir loader's `load`/`actions`; impact-granularity (coverage already 100%), 0 cross-dir mislinks, nodes unchanged, regression test added. Commit 37 (`1dec765`): Nuxt nested auto-imported component resolution — `vueTemplateEdges` now matches PascalCase tags + a `nuxtComponentName` map (``→`media/Card.vue`); nuxt/movies 47.6%→93.5%, +9 nested components, Vue-only (gated to .vue), nodes unchanged, regression test added. +- uncommitted: only untracked `assets/generate-waitlist.py` (unrelated — README-waitlist tooling; the `.claude/handoffs/*` files are committed on this branch). +- Touched source files (branch vs main): `src/db/queries.ts`, `src/graph/queries.ts`, `src/extraction/{tree-sitter,tree-sitter-types,grammars}.ts`, `src/extraction/razor-extractor.ts` (NEW — Razor/Blazor markup), `src/extraction/languages/{rust,java,csharp,kotlin,php,ruby,scala,c-cpp}.ts`, `src/resolution/{import-resolver,callback-synthesizer,index,name-matcher}.ts`, `src/resolution/frameworks/{python,react-native,expo-modules}.ts`, `src/types.ts`, `__tests__/{extraction,graph,expo-modules,react-native-bridge,rn-event-channel}.test.ts`, `CHANGELOG.md`. +- Measurement scripts (in /tmp, not committed): `faircov.cjs` (honest fair coverage + false-edge count, `--list` shows residual zeros + exclusions), `audit.cjs` (lists 0-dependent files by language), `xlang.cjs` (cross-lang edges by src→tgt × kind). + +## Open threads / TODO +- [x] **Kotlin DONE** (commit d8a2e91) — gap was KMP `expect`/`actual`; coroutines 76.8%→93.5%, OkHttp already 96.2%. See "Kotlin result" above. +- [x] **Scala DONE** (commit b5489d9) — gap was a whole family of missing edges (parameterized extends, type refs, implicit/context-bound params, `new`); cats 48.9%→89.2% fair, gatling 76.3%→91.2%. See "Scala result" above. +- [x] **PHP DONE** (commit acfb444) — gap was NAMESPACES (not #608/#660); guzzle 95.2%→100%, laravel 80.5%→94.9%. See "PHP result" above. +- [x] **Ruby DONE** (commits 44fb978 + 5bccab6) — gaps were MIXINS + REQUIRE resolution; activerecord 84.8%→96.8%, sidekiq 71%→**100%**. See "Ruby result" above. +- [x] **C/C++ DONE** (commit ec8fe3f) — gap was a C++ free-function name-extraction bug; leveldb 91.7%→94.8%, redis (C) 92.2% at ceiling. See "C/C++ result" above. +- [x] **Dart DONE** (commit 9487954) — gaps were mixins (`with`) + method type refs; flutter/packages 88.8%→92.4%, dio 86.4%→87.9%. See "Dart result" above. +- [ ] **Objective-C next** (last README language, already partial — `@interface`/`@implementation` split, `#import`, categories, protocols, `@property`). Niche after: Liquid, Pascal, Lua, Luau. +- [x] **Static-member/value-read pass DONE** (commit 857baf7) — `Enum.value`/`Type.CONST`/`Foo::BAR` → references; flutter 92.4%→93.2%, additive across Java/C#/Kotlin/Swift/Scala/PHP/C++. TS/JS/Python excluded. +- [x] **Objective-C DONE** (commit 33ce431) — selectors + class-receiver + #import + class-method resolution; AFNetworking 50%→90%, SDWebImage Core 33.8%→91.6%. ← LAST README language. +- [x] **Per-language + RN/Expo campaign COMPLETE** (`16b5633`→`529d822`): all 15 README langs + static-member pass + cross-language RN/Expo at 95%+. +- [x] **Route-framework front — import/aggregator style DONE** (`61a993a`→`2a0b6e0`): Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%. See the headroom map. +- [~] **Route-framework front — convention/reflection style PARTIAL** (`4c14413`,`59b8de2`,`90c5f39`,`dc7d033`,`9e5a951`): ASP.NET eShopOnWeb 59.3%→83.9% (Razor/Blazor parser + C# namespaces), Django realworld 45.9%→74.1%. At an honest sub-95% static-analysis ceiling (markup-driven code-behind + reflection/DI). Reaching 95% needs markup→code linking or per-framework reflection modeling (large features); faking it via metric exclusions = gaming, NOT done. +- [x] **Spring DONE** (83.3% fair, 15/18) — the real blocker was faircov NOT excluding `package-info.java` shells (5 in petclinic), not a `samples`-dir exclusion (which never existed in the script). Fixed → barrel-exclude `package-info.java`/`module-info.java`. 3 residual zeros = `@Configuration` beans + AOT `RuntimeHints`, all reflection-registered → the convention/reflection ceiling, on par with ASP.NET 83.9%. +- [x] **Axum (Rust) DONE** (72.7%→**100%**, a3f59fb) — import/aggregator thesis confirmed in a 3rd language family. Found+fixed a REAL engine miss (not a metric issue): bare Rust `submodule::fn()` calls (the `X::router()` router-assembly pattern) resolved crate-relative only → now self-relative FIRST (current module), then crate fallback. Clean A/B: no control regression (ripgrep neutral 66/79, tokio +3 → 260/321); cross-family false edges 0; regression test fails without the fix. NOTE: actix/Rocket NOW MEASURED (below). +- [x] **Rocket (Rust) DONE** (62.5%→68.8%→**93.8%**, 7bb958b + 6d214cd) — TWO engine wins: (1) multi-segment `a::b::c()` calls were dropped by the resolver pre-filter (leaf never checked) → fixed → +database/profiles.rs; (2) built the `routes![]`/`catchers![]` macro extractor — parses handler paths out of the raw token-tree + resolves them via the Rust path resolver → all 4 `routes/*.rs` handler modules covered. Only `lib.rs` (crate-root/launch hub, see-through) remains → effectively at ceiling. No control regression, false edges 0, nodes unchanged (edges only). +- [x] **actix-web (Rust) DONE** (**65.4%**, genuine ceiling) — actix 1.x ACTOR style: routing resolves (handlers covered), but the DB layer is reached via actor message dispatch (`db.send(Msg)` → `impl Handler for DbExecutor`) — a DYNAMIC runtime frontier with no static edge (deliberately uncovered, the actix analog of MediatR/reactive runtimes). + glob `pub use self::x::*` re-export roots. Bridging needs an actor-message synthesizer (dynamic-dispatch feature), not a static fix. +- [x] **Vapor (Swift) DONE** — vapor-til (canonical) 85.7%→**100%** (bb7659e), penny-bot (real prod, 166 files) 73.2% (serverless/DI, secondary). Import/aggregator confirmed in Swift: controllers via `app.register(collection: X())`, models via controller queries + migrations. REAL miss found+fixed: a many-to-many pivot model referenced ONLY via `@Siblings(through: Pivot.self)` (metatype arg in a property-wrapper attribute, not walked) → routed attribute args through `extractStaticMemberRef` → pivot covered. + faircov excl `Package.swift` + `Public/`. penny-bot's lower number = AWS Lambda entries + `exports.swift` barrels (faircov gaps, ~80% if excluded) + DI/serialization/extension frontiers (genuine); not chased. +- [x] **SvelteKit (Svelte) DONE** (35.9% raw → **100% fair**, NO commit — first framework needing NO engine fix) — sveltejs/realworld (official). The `.svelte` import resolution ALREADY connects the whole component graph (page→component→lib — verified: imports+references+calls all resolve, e.g. ListErrors←4 pages). Low raw = the file-convention shape: 25/39 files are SvelteKit convention ENTRIES (`+page`/`+layout`/`+error`/`+server`/`hooks` — framework-discovered route leaves, no in-repo caller) → excluded in faircov → 14/14 = 100% of the coverable core. ONE framework-mediated gap: `+page.server.js` `load` → `+page.svelte` `data` (runtime `data` prop, not static) — bridged with a SvelteKit convention synthesizer (sibling-by-path, like Rocket routes!) — impact-granularity not coverage (both files are entries). **BUILT (3ea03e5): `svelteKitLoadEdges`** (callback-synthesizer.ts) links each page → its own-dir loader's `load`/`actions` + `+layout`; 19 links / 0 cross-dir mislinks on realworld, nodes unchanged, regression test (incl. does-not-cross-routes guard). +- [x] **React Router (React) DONE** (**100%**, 29/29, NO commit — no engine miss) — gothinkster realworld. The CONFIG-based component-node sub-shape: route components are NAMED in source (``) → App.js imports+references each → all resolve (verified Login/Editor/Profile covered by App.js's route config). Only `index.js` (CRA entry) + 8 anonymous-default reducers (0 symbols → barrels) excluded. The component-node category is now fully characterized: config-based (React Router, source-referenced → 100% raw) vs file-convention (SvelteKit, path-discovered → fair-100% + synthesizer); the engine handles BOTH as-is. (Modern v6/v7 `element={}`/data-router `{element, loader}` also source-referenced → same.) +- [x] **Nuxt (Vue) DONE** (47.6% → **93.5% fair**, 1dec765) — nuxt/movies (official demo). File-convention component framework (like SvelteKit) but with an AUTO-IMPORT twist that WAS a real miss: Nuxt auto-imports a NESTED component by a DIR-PREFIXED name (`components/media/Card.vue` → ``), but the node is named `Card` → the PascalCase usage never resolved. Fixed `vueTemplateEdges` (match PascalCase tags, not just kebab + a `nuxtComponentName` map) → +9 nested components. + faircov Nuxt entry exclusions. composables (`useTmdb()`) already resolved by name-match. 2 residual zeros = genuine frontiers (unused `` + unimported `constants/images.ts`). **KEY contrast:** SvelteKit needed NO component fix, Nuxt DID — its dir-prefix auto-import naming is unique. The Vue PascalCase-tag change is gated to `.vue` files (suite green, false edges 0). +- [x] **Drupal (PHP) DONE** (57.1% raw → **78.9% fair**, NO commit — reflection ceiling, no simple miss) — token contrib module. The MOST reflection-heavy framework. Static parts WORK: `*.routing.yml`→controller (drupal.ts), PHP class refs. 4 residual zeros all DI/reflection: 2 `services.yml` services (`TreeBuilder`/`TokenFieldRender`), a dynamically-routed controller (`TokenDevelController` via `RouteSubscriber`), a field-plugin class. + faircov excl JS libraries + convention entries. Sits with ASP.NET 83.9% / Django 74.1% in the convention/reflection band. LEVERS = drupal.ts TODOs (model `services.yml` DI → +2 services; plugin annotations; modern `#[Hook]` attributes) — large per-mechanism features, NOT chased (same stance as ASP.NET/Spring/Django: don't fake 95% via reflection modeling). **← LAST framework; the sweep is complete.** +- [ ] **Frameworks not yet measured** (need a canonical app cloned): Drupal, React Router, Vue-Nuxt (SvelteKit DONE: 100% fair — component-node shape characterized, NO engine miss; Rust route DONE: Axum 100%, Rocket 93.8%, actix 65.4%; Vapor DONE: vapor-til 100%, penny-bot 73.2% serverless/DI) (component-node frameworks — coverage shape differs). The headroom map calls these the real remaining headroom. +- [x] **All 4 niche README languages DONE** (Lua 84.2%, Luau 92.2%, Liquid 73.8%, Pascal 75.7%) — see the per-language table. Full README parity (22 langs + 14 frameworks). +- [ ] **Open the PR to `main`** (41 commits) — everything actionable is done. Deferred polish (genuinely optional): C function-pointer dispatch + C++ namespace capture; PHP IFACE_OVERRIDE_LANGS + type-hint disambiguation; Scala wildcard imports; ObjC type refs; engine symlink-dedup (SDWebImage `include/` dup file nodes); a convention/reflection lever (Drupal services.yml DI / ASP.NET-Spring reflection modeling — large per-framework features). (Static-member pass for TS/JS/Python is NOT a lever — measured & rejected, e7b86df.) +- [x] **Static-member/const pass for TS/JS/Python MEASURED & REJECTED** (e7b86df) — the long-standing "one big cross-language lever." A/B (extend `extractStaticMemberRef` to ts/js/python): **0 coverage gain** on excalidraw (94.3%→94.3%) AND requests (100%→100%) — in import-based languages a `Type.MEMBER` read requires `import`ing the type, so the import edge already covers it (the static read is pure duplication) — while adding **+1813 edges / +2448 `references` on excalidraw** (the retrieval-perf benchmark), all pointing at already-covered types. Reverted; finding documented in the `STATIC_MEMBER_LANGS` comment so it isn't re-tried. The deferral was CORRECT — now proven with data. +- [ ] Cross-language **static-member / const value-read** pass (`X.FACTORY`, `JsonScope.X`, `Foo.class`) — the universal deferred data-flow frontier; would lift Java/C#/etc. ~5% more but risks noise. User aware; deferred. +- [ ] Optional: broaden `getCallers`/`getCallees` edge-kind list to include `instantiates`/`implements`/`decorates` (offered, deferred — changes what "caller" means). +- [ ] Branch is NOT merged — it's for review. Maintainer handles version bumps/releases. Don't merge or bump. + +## Recent transcript (oldest → newest) +### Turn — "Rust next" +- **Claude:** Cloned ripgrep+tokio. Found struct literals (`struct_expression`) not in INSTANTIATION_KINDS, `use` bindings not linked, trait methods (`function_signature_item`) not extracted. Fixed all + added rust to IFACE_OVERRIDE_LANGS. ripgrep 63%→81%, tokio→83%. → commits b538aee, then 2ac7df5 (module-path resolution for `pub use self::read::read` collisions → ripgrep 87%). +### Turn — "do fixes translate to explore/callers/callees or just impact?" +- **Claude:** Verified against tool code → graph-wide; mapped edge-kinds per tool (callers/callees = calls/references/imports; impact/explore = all-but-contains). instantiates/implements/decorates only in impact/explore. +### Turn — "Java next" → "is Java good or can we improve it?" +- **Claude:** Found Java annotation gap (usages in `modifiers` not descended; `@interface` not in interfaceTypes). Fixed both → gson 78%→85% (commit badb124). On re-push ask: pushed already. On "is it good": fair re-measure showed Java actually **93.3%/94.9%** (raw was polluted by package-info/.kt/samples); residual = universal value-read frontier. Concluded Java is good. +### Turn — "Swift next" +- **Claude:** Alamofire already 93%. argparser 84.6% — found property-wrapper gap (`@Argument` = Swift `attribute`→`user_type`, not recognized; Swift instance props not extracted). Extended `extractDecoratorsFor` + dispatcher branch. Alamofire→95.3%, argparser→96.2%. Commit d111f26. Completes README benchmarked 7. +### Turn — /handoff save (original, 2026-06-04) +- Saved at commit 529d822 (per-language + RN/Expo campaign). +### Session — ROUTE-FRAMEWORK front (2026-06-04→05, commits `61a993a`→`b653688`; reconstructed from commits) +- Measured a canonical real app per README route framework. Import/aggregator frameworks made reachable: Python absolute `import a.b.c` (61a993a), source-aware `from pkg import submodule` for FastAPI aggregators (2835623), Django `include('app.urls')` (58dc463), renamed default-import→module file for Express controllers (2a0b6e0) → Express 100%, FastAPI 98%, Flask/requests 100%, NestJS 96.8%, Gin 96.5%. +- Convention/reflection (ASP.NET/Blazor): built a Razor/Blazor `.cshtml`/`.razor` markup parser (59b8de2), delegated `@code`/`@{}` to the C# extractor (90c5f39), added C# namespace extraction (dc7d033) + Razor `@using` disambiguation (9e5a951), resolved chained `a.b.Method()` incl. C# extension methods (4c14413) → eShopOnWeb 59.3%→83.9%, Django→74.1%. Concluded these hit a genuine static-analysis ceiling (markup/reflection); did NOT game the metric to fake 95%. +### Session — this update (2026-06-05) +- **User:** loaded the wrong handoff first (explore-overhaul-bench), asked about per-language coverage %, then switched to this branch to work on impact coverage. +- **Claude:** sourced the coverage table from the `impact-coverage-findings` memory (corroborated by this handoff's own table); found this handoff committed on the branch + loaded it; flagged the branch had moved 11 commits past the handoff's stated tip; **refreshed this handoff to current state** (31 commits, route-framework front, fork of next steps; test count re-verified 1178). Then deleted the other 9 handoff files at the user's request, leaving this as the sole handoff. +- **Outcome:** handoff is current + the only one left; awaiting the user's pick of fork (ship / route front / static-member pass). +### Session — Spring + Axum (2026-06-05, commit a3f59fb) +- **User:** "unblock spring by fixing the faircov samples bug" → then "yes" to measuring an unmeasured framework. +- **Claude (Spring):** there was NO `samples`-dir exclusion in faircov (the handoff note was wrong); the real deflation was 5 `package-info.java` shells (namespace-only → realSyms≠0) sitting in the denominator as zeros. Fixed faircov to barrel-exclude `package-info.java`/`module-info.java`. Spring petclinic 65.2%→**83.3%** (15/18); 3 residual zeros all reflection/DI wiring (`@Configuration` + AOT hints — verified honest, not missed edges) → convention/reflection ceiling, ≈ASP.NET 83.9%. +- **Claude (Axum):** measured realworld-axum-sqlx → 72.7%, audited zeros → found a REAL engine miss (not a metric issue): bare Rust `submodule::fn()` calls (`X::router()` router-assembly) resolved crate-relative only. Fixed `resolveRustModuleFile` to try self-relative FIRST, then crate fallback; + `main.rs`/`src/bin/*.rs` faircov entry exclusion. Axum → **100%** (10/10). Clean A/B (same faircov, engine ±fix): axum +2, ripgrep neutral 66/79, tokio +3 → 260/321; cross-family false edges 0; regression test fails without the fix; full suite **1179** green. Committed+pushed `a3f59fb`. +- **Outcome:** Spring + Axum both measured; the import/aggregator → 95%+ thesis now holds in a 3rd language family (Rust) via a real engine fix, not a metric tweak. Remaining unmeasured: actix/Rocket (attribute-macro routing — likely convention/reflection shape), Vapor, React Router/SvelteKit/Vue-Nuxt, Drupal. +### Session — Rust attribute-macro frameworks (2026-06-05, commit 7bb958b) +- **User:** "rust frameworks to probe the ceiling" (actix + Rocket). +- **Claude:** measured Rocket (TatriX/realworld-rust-rocket) 62.5% + actix (fairingrey/actix-realworld) 65.4%. Audited zeros → TWO findings. (1) Rocket's 3-segment `database::profiles::find()` calls weren't becoming edges — isolated via micro-tests to a RESOLVER PRE-FILTER bug (`hasAnyPossibleMatch` checked first-seg + the `b::c` remainder, never the leaf), NOT closures or extraction (both fine). Fixed (leaf-name check) → Rocket 68.8%, additive elsewhere, no control regression, false edges 0. Committed 7bb958b + regression test. (2) Remaining ceilings are GENUINE: Rocket's `routes![]` macro-mounted handlers (bridgeable via a Rocket extractor — deferred, framework-specific) + actix's actor message dispatch (`db.send(Msg)`→`impl Handler`, a dynamic frontier like MediatR — not bridged). +- **Outcome:** both measured; the "attribute-macro = ceiling" hypothesis was PARTLY right (genuine ceilings exist) but probing surfaced a real generalizable resolver bug that benefits all Rust `a::b::c()` calls. All Rust route frameworks done. Next options: Vapor / component-node frameworks / Drupal, or build the Rocket `routes![]` extractor (→~94%). +### Session — Rocket routes![] extractor (2026-06-05, commit 6d214cd) +- **User:** "build the Rocket routes! extractor". +- **Claude:** probed the tree-sitter AST — `routes![a::b::h, …]` parses as `macro_invocation > token_tree` of FLAT raw tokens (`identifier` / `::` / `,`), so the handler paths were invisible to the call walker. Built `extractRustRouteMacro` (tree-sitter.ts, called from `visitFunctionBody`): for a `routes`/`catchers` macro, walk the token-tree, group `identifier` runs between commas into `::`-joined paths, emit a `references` edge each; the existing Rust path resolver (self-relative + multi-segment fixes already shipped) links them to the handler fns. Rocket 68.8%→**93.8%** (all 4 `routes/*.rs` covered; only lib.rs root left). No control regression, false edges 0, nodes unchanged (edges only), regression test fails without it. Committed 6d214cd. +- **Outcome:** Rocket effectively at ceiling. Handler names are explicit in source → precise static extraction, not a heuristic. Remaining route-front: Vapor (Swift), component-node frameworks (SvelteKit/React Router/Vue-Nuxt), Drupal — or ship the 34-commit branch. +### Session — Vapor (2026-06-06, commit bb7659e) +- **User:** "vapor next". +- **Claude:** measured vapor-til (canonical Vapor tutorial app, kodecocodes) 85.7% + penny-bot (real prod, 166 files) 73.2%. vapor-til zeros: Package.swift (SPM build manifest → faircov excl), Public/cookies.js (static client asset → faircov excl), and AcronymCategoryPivot.swift = a REAL miss. Audited: the pivot model is referenced ONLY via `@Siblings(through: AcronymCategoryPivot.self, …)` in Acronym/Category — a metatype arg inside a property-wrapper `attribute` whose ARGS were never walked (only the wrapper type `Siblings` + the property's declared type were captured). Fix: route the property's attribute-arg subtree through `extractStaticMemberRef` (already handles Swift `Type.self` navigation, self-filters to capitalized receivers, skips `\.$keypath`). vapor-til 94.7%→**100%**. Clean A/B: vapor-til +1, Alamofire neutral (44/50); false edges 0; suite 1182. Committed bb7659e + regression test. penny-bot 73.2% characterized (serverless/DI: Lambda entries + exports barrels + DI/serialization/extension frontiers) — NOT chased (not the canonical pattern). +- **Outcome:** Vapor DONE; import/aggregator confirmed in a 4th language family (Swift) via a real Fluent fix. Remaining route-front: component-node frameworks (SvelteKit/React Router/Vue-Nuxt), Drupal — or ship the 35-commit branch. +### Session — SvelteKit (2026-06-06, no commit — characterization) +- **User:** "component-node framework next". +- **Claude:** measured sveltejs/realworld (official SvelteKit app) → 35.9% raw (14/39). Audited: ALL 25 zeros are SvelteKit file-convention files (`+page`/`+layout`/`+error`/`+server`/`hooks`) — loaded by the framework BY PATH, no in-repo caller. Probed + CONFIRMED the engine already resolves every `.svelte` component import (ArticlePreview←ArticleList, Nav←+layout, ListErrors←4 pages; imports+references+calls all resolve) — NO component is a zero, so NO engine miss (first framework needing none). Added SvelteKit convention-entry patterns to faircov (methodology-aligned, like route handlers) → 14/14 = **100% fair** of the coverable core. Confirmed the one framework-mediated gap: `+page.server.js` `load` → `+page.svelte` `data` is NOT statically linked (runtime `data` prop). +- **Outcome:** the component-node shape is "file-convention entries (the majority) + a fully-covered component/lib core" — the engine handles it as-is. No commit (nothing to fix). OFFERED a SvelteKit convention synthesizer (sibling `+page.server.js`↔`+page.svelte` link, like Rocket routes!) for impact granularity — not built. Branch stays 35 commits. Next: React Router (config-based variant — likely MORE covered) / Nuxt (file-convention, SvelteKit-like) / Drupal, or ship. +### Session — SvelteKit load→page synthesizer (2026-06-06, commit 3ea03e5) +- **User:** "lets fix svelte with the synthesizer, then we can move onto react router". +- **Claude:** probed the graph: `+page.svelte`→`component` node, `+page.server.js`→`load`/`actions` function nodes, pages pair with sibling loaders in the SAME dir. Built `svelteKitLoadEdges` (callback-synthesizer.ts, wired into `synthesizeCallbackEdges` after `vueTemplateEdges`): for each `+page`/`+layout.svelte`, link its `component` → the `load`/`actions` in `/.server.{ts,js}` / `/.{ts,js}` (same dir only), direction page→load so `getImpactRadius(load)` surfaces the page; provenance heuristic. Verified on sveltejs/realworld: 19 links, **0 cross-dir mislinks**, false edges 0, nodes unchanged; `getImpactRadius(login load)` now returns login/+page.svelte. Committed 3ea03e5 + regression test (incl. a does-not-cross-routes guard). Suite 1183. +- **Outcome:** SvelteKit fully done (component coverage 100% + the loader→page impact link). Next per the user: React Router (the config-based component-node variant). +### Session — React Router (2026-06-06, no commit — characterization) +- **User:** "...then we can move onto react router" (continued from the svelte synthesizer). +- **Claude:** measured gothinkster react-redux-realworld (38 files, react-router v4/v5) → **100%** (29/29), false edges 0. The CONFIG-based component-node sub-shape: route components are NAMED in source (``), so App.js imports+references each → all resolve. Verified Login/Editor/Profile covered BY App.js's route config. Only `index.js` (CRA entry) + 8 anonymous-default reducers (0 named symbols → barrels) excluded. NO engine miss. (Modern v6/v7 `element={}` + data-router `{element, loader}` are also source-referenced → same conclusion.) +- **Outcome:** React Router DONE. Component-node category now fully characterized — two sub-shapes (config-based React Router = 100% raw; file-convention SvelteKit = fair-100% + synthesizer), both handled by the engine. No commit (nothing to fix). Remaining: Nuxt (Vue, file-convention — would mirror SvelteKit), Drupal (PHP), or ship the 36-commit branch. +### Session — Nuxt (2026-06-06, commit 1dec765) +- **User:** "nuxt next". +- **Claude:** measured nuxt/movies (official Nuxt 3 demo, 42 .vue + 37 ts) → 47.6% raw. Audited zeros: pages/app.vue/error.vue/server = file-convention entries (→ faircov Nuxt exclusions); NESTED components (`components/media/*`, `components/carousel/*`) = a REAL miss. Root cause: Nuxt auto-imports a nested component by DIR-PREFIX (`media/Card.vue` → ``), but the node is named `Card`; the synth `vueTemplateEdges` only matched KEBAB tags, and the extractor's PascalCase ref name-matched `MediaCard`→nothing. Fixed: added a PascalCase-tag regex to vueTemplateEdges + a `nuxtComponentName` map (`media/Card.vue`→`MediaCard`, Nuxt dir de-dup). nuxt/movies 47.6%→**93.5%** (+9 nested components). Vue-only (gated to .vue), false edges 0, nodes unchanged, suite 1183, regression test. Committed 1dec765. 2 residual zeros are genuine frontiers (unused ``, unimported constants). composables (`useTmdb()`) already worked. +- **Outcome:** Nuxt DONE. The two file-convention component frameworks DIFFER: SvelteKit's imports resolve as-is (no fix), Nuxt's dir-prefix auto-import needed a fix. Only **Drupal** (PHP) left unmeasured — or ship the 37-commit branch. +### Session — Drupal (2026-06-06, no commit — characterization, finishes the sweep) +- **User:** "drupal to finish the sweep". +- **Claude:** measured the `token` contrib module (58 PHP, modern Drupal 11) → 57.1% raw. Audited: `drupal.ts` ALREADY resolves `*.routing.yml`→controller (TokenTreeController covered via the route ref) and PHP class refs work; the zeros are ALL genuine reflection/convention — JS libraries, DI services (`TreeBuilder` = `@token.tree_builder` in services.yml, `TokenFieldRender`), a dynamically-routed controller (`TokenDevelController` via `RouteSubscriber`), plugins (`Element/`, `Plugin/Derivative/`), Drush commands, `*ServiceProvider`, `.install` hooks, field-plugin `MenuLinkFieldItemList`. NO simple fixable miss (verified TokenDevelController isn't in any routing.yml — genuinely dynamic). Added faircov Drupal convention-entry + JS-library exclusions → **78.9% fair** (4 residual zeros = DI services + dynamic route + field plugin). Token uses modern `#[Hook('...')]` attribute OOP hooks (a drupal.ts TODO). NO commit (the levers — services.yml DI / plugin annotations / `#[Hook]` — are large per-mechanism features, same don't-chase-95% stance as ASP.NET/Django/Spring). +- **Outcome:** **THE SWEEP IS COMPLETE.** Every README framework measured: import/aggregator + config-based-component + file-convention-component reach ceiling (95–100%) via 6 engine fixes; convention/reflection/actor frameworks (ASP.NET/Spring/Drupal/Django/actix) sit at a genuine sub-95% reflection ceiling. Branch 37 commits, ready to ship. Next: open the PR, or the static-member-pass TS/JS/Python lever. +### Session — static-member pass for TS/JS/Python: measured & rejected (2026-06-06, commit e7b86df) +- **User:** "extending the static-member/const value-read pass to TS/JS/Python." +- **Claude:** implemented (added `member_expression`/`attribute` to `MEMBER_ACCESS_TYPES` + ts/js/python to `STATIC_MEMBER_LANGS`), then ran a clean A/B (same faircov, engine ±change) on excalidraw (TS/React, the retrieval-perf canary) + requests (Python). Result: **0 coverage gain** on BOTH (excalidraw 94.3%→94.3%, requests 100%→100%) because the import edge already covers every `Type.MEMBER` read (in import-based languages you must import the type first — the static read is redundant), while adding **+1813 edges / +2448 `references` on excalidraw** (all pointing at already-covered types — pure retrieval-graph noise). REVERTED the functional change; enhanced the `STATIC_MEMBER_LANGS` comment with the measured finding so it isn't re-attempted. Committed e7b86df (doc-only). +- **Outcome:** the standing "one big cross-language lever" is CLOSED — the deferral was correct, now proven with data. The branch is 38 commits; everything actionable is done. **Recommended next: open the PR to main.** +### Session — the 4 niche README languages (2026-06-06, commits 4155609 / 2f57119 / 2f30a3b) +- **User:** confirmed only 4 README "Full support" languages lacked coverage validation (Lua/Luau/Liquid/Pascal); "knock out those 4 to close it out." +- **Claude:** measured + fixed each. **Lua** (telescope) 31.6% + **Luau** (Fusion) 12.5%: NO `require()` module resolver existed — added `resolveLuaRequire` (dotted `a.b.c`→file + instance-path leaf, path-suffix match w/ same-dir preference; confidence 0.9 so it beats the import-node self-match) → Lua **84.2%**, Luau **92.2%** (4155609). **Liquid** (Dawn) 39.1%: snippet `{% render %}` already worked, but Shopify OS 2.0 sections are referenced from JSON templates (`templates/*.json` `"type"`), which weren't even indexed — made Shopify JSON indexable (isSourceFile + detectLanguage) + a Liquid-extractor JSON branch emitting `sections/.liquid` refs (incl. nested `templates/customers/`) → **73.8%** (2f57119). **Pascal** (PascalCoin) 73.0%: `uses`-resolution ALREADY worked; added a `.dfm`/`.fmx`↔`.pas` form-code-behind synthesizer → **75.7%** (2f30a3b). Each: gated to its language, false edges 0, regression test fails without the fix, suite green (1187). +- **Outcome:** **FULL README PARITY** — all 22 "Full support" languages + all 14 frameworks now have coverage validation. Branch 41 commits, ready to ship. 3 new generalizable resolvers (Lua/Luau require, Shopify-JSON sections, Delphi form pairing). Only remaining work is optional reflection/DI levers + the PR. diff --git a/.claude/skills/add-lang/SKILL.md b/.claude/skills/add-lang/SKILL.md index 0e107a3e6..37cbdce55 100644 --- a/.claude/skills/add-lang/SKILL.md +++ b/.claude/skills/add-lang/SKILL.md @@ -189,8 +189,8 @@ Read each `parse-run.mjs` summary printed by `run-all.sh`: tool calls, file - **CHANGELOG.md**: add an `## [Unreleased]` section at the top (above the latest version) with `### Added` → a user-perspective bullet, e.g. *"CodeGraph now indexes **** (`.ext`) — functions, classes, imports, and - call edges."* If `## [Unreleased]` already exists, append under it. (`/publish` - folds this into the next versioned block at release time.) + call edges."* If `## [Unreleased]` already exists, append under it. (It's + folded into the next versioned block at release time.) ### Step 10 — Report (do NOT commit) @@ -204,8 +204,8 @@ Summarize for review: - **Gaps / follow-ups** (node types not yet mapped, resolution edges missing, framework routes, etc.). -Hand the changes to the user. **Do not** run `git commit`/`push`, -`npm publish`, or `scripts/release.sh`. +Hand the changes to the user. **Do not** run `git commit`/`push` or publish — +releases go through the GitHub Actions Release workflow. ## Notes - The A/B spawns real **paid** `claude -p` runs (opus, `--max-budget-usd`), diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 3dcc87524..2cfedac4f 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -11,7 +11,8 @@ "Go": [ { "name": "cobra", "repo": "https://github.com/spf13/cobra", "size": "Small", "files": "~50", "question": "How does cobra parse commands and flags?" }, { "name": "gin", "repo": "https://github.com/gin-gonic/gin", "size": "Medium", "files": "~150", "question": "How does gin route requests through its middleware chain?" }, - { "name": "terraform", "repo": "https://github.com/hashicorp/terraform", "size": "Large", "files": "~4000", "question": "How does Terraform build and walk the resource dependency graph?" } + { "name": "terraform", "repo": "https://github.com/hashicorp/terraform", "size": "Large", "files": "~4000", "question": "How does Terraform build and walk the resource dependency graph?" }, + { "name": "cosmos-sdk", "repo": "https://github.com/cosmos/cosmos-sdk", "size": "Large", "files": "~5000", "question": "How does a bank module MsgSend message reach the account balance update? Trace the cross-module call path from the bank keeper's Send handler through to the account/balance store update." } ], "Python": [ { "name": "click", "repo": "https://github.com/pallets/click", "size": "Small", "files": "~60", "question": "How does click parse command-line arguments into commands?" }, @@ -69,5 +70,29 @@ { "name": "Knit", "repo": "https://github.com/Sleitnick/Knit", "size": "Small", "files": "~10", "question": "How does Knit register services and expose them to clients?" }, { "name": "vide", "repo": "https://github.com/centau/vide", "size": "Small", "files": "~40", "question": "How does vide track reactive sources and re-run effects when state changes?" }, { "name": "Fusion", "repo": "https://github.com/dphfox/Fusion", "size": "Medium", "files": "~115", "question": "How does Fusion build and update its reactive UI graph from state objects?" } + ], + "Objective-C": [ + { "name": "Masonry", "repo": "https://github.com/SnapKit/Masonry", "size": "Small", "files": "~50", "question": "How does Masonry build and activate Auto Layout constraints from its block DSL?" }, + { "name": "FMDB", "repo": "https://github.com/ccgus/fmdb", "size": "Medium", "files": "~80", "question": "How does FMDB execute a prepared SQL statement and bind parameters?" }, + { "name": "SDWebImage", "repo": "https://github.com/SDWebImage/SDWebImage", "size": "Large", "files": "~400", "question": "How does SDWebImage download, cache, and decode an image for a UIImageView?" } + ], + "Mixed iOS (Swift+ObjC)": [ + { "name": "Charts", "repo": "https://github.com/danielgindi/Charts", "size": "Small", "files": "~270", "question": "How does the ChartsDemo ObjC demo controller drive the Swift Charts library to animate and notify a data update?" }, + { "name": "realm-swift", "repo": "https://github.com/realm/realm-swift", "size": "Medium", "files": "~370", "question": "How does a Swift `Realm.write { realm.add(obj) }` reach the Objective-C persistence layer?" }, + { "name": "wikipedia-ios", "repo": "https://github.com/wikimedia/wikipedia-ios", "size": "Large", "files": "~1700", "question": "How does tapping a search result reach the article-fetch network call across the Swift / ObjC boundary?" } + ], + "React Native (legacy bridge + TurboModule)": [ + { "name": "@react-native-async-storage", "repo": "https://github.com/react-native-async-storage/async-storage", "size": "Small", "files": "~60", "question": "How does `setItem` in JS reach the native `legacy_multiSet` implementation?" }, + { "name": "react-native-svg", "repo": "https://github.com/software-mansion/react-native-svg", "size": "Medium", "files": "~700", "question": "How does a JS `Svg.getTotalLength(...)` reach the iOS / Android native implementation via TurboModule?" }, + { "name": "react-native-firebase", "repo": "https://github.com/invertase/react-native-firebase", "size": "Large", "files": "~1100", "question": "How does a native iOS push notification reach the JS `messaging().onMessage(...)` listener?" } + ], + "Expo Modules": [ + { "name": "expo-haptics", "repo": "https://github.com/expo/expo/tree/main/packages/expo-haptics", "size": "Small", "files": "~15", "question": "How does `Haptics.notificationAsync(...)` in JS reach `UINotificationFeedbackGenerator` in the Swift Module?" }, + { "name": "expo-camera", "repo": "https://github.com/expo/expo/tree/main/packages/expo-camera", "size": "Medium", "files": "~70", "question": "How does a JS `CameraView.takePictureAsync(options)` reach the native AVCaptureSession / CameraDevice call?" } + ], + "React Native Fabric (view components)": [ + { "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `` reach the native onChange handler on iOS/Android?" }, + { "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `` reach the native RNSScreenStackView component?" }, + { "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `` JSX usage reach the iOS / Android native renderer?" } ] } diff --git a/.claude/skills/publish/SKILL.md b/.claude/skills/publish/SKILL.md deleted file mode 100644 index 84c6d4b3e..000000000 --- a/.claude/skills/publish/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: publish -description: Publishes a new minor or major release of this npm package (codegraph). Reads the latest version from npm, generates a user-perspective CHANGELOG entry from commits since the last tag, bumps package.json, publishes to npm, and creates the matching GitHub release. Use when the user runs /publish or asks to cut, ship, or publish a release / new version. ---- - -# Publish a release - -Cut a **minor or major** release: generate the changelog, bump, publish to npm, and create the GitHub release. Patch releases are intentionally not offered here. - -This skill performs the actual publish (npm publish, git push, GitHub release) — that is the whole point of invoking it, so the general "hand the user the commands" rule does **not** apply inside `/publish`. The **confirmation gate in Step 5 is the safeguard**: never run a step past it without explicit approval. - -Run from the repo root. - -## Workflow - -Copy this checklist and work through it in order: - -``` -- [ ] 1. Preflight: branch, sync, auth -- [ ] 2. Read base version from npm, compute candidates -- [ ] 3. Ask the user: minor or major -- [ ] 4. Generate the CHANGELOG entry from commits since the last tag -- [ ] 5. CONFIRMATION GATE — show changelog + plan, get explicit approval -- [ ] 6. Write CHANGELOG.md, bump, build -- [ ] 7. Commit + push -- [ ] 8. npm publish -- [ ] 9. scripts/release.sh (GitHub release) -- [ ] 10. Verify on the npm registry -``` - -### Step 1 — Preflight - -```bash -git rev-parse --abbrev-ref HEAD # expect: main -git fetch origin -git status --porcelain # working tree should be clean -git rev-list --left-right --count origin/main...HEAD # " " -npm whoami # npm auth (publish will fail without it) -gh auth status # gh auth (release.sh needs it) -``` - -- If not on `main`, stop and ask the user to confirm releasing from this branch. -- If behind origin, `git pull --ff-only` so the final push is a fast-forward. -- If the tree has **unrelated** uncommitted changes, stop and ask — the release commit only stages 3 files, but a dirty tree usually means something's mid-flight. -- If `npm whoami` or `gh auth status` fails, stop and tell the user to authenticate. - -### Step 2 — Base version + candidates - -The latest **published** version is the source of truth, not local `package.json`. - -```bash -PKG=$(node -p "require('./package.json').name") -BASE=$(npm view "$PKG" version) -node -e "const [a,b]=process.argv[1].split('.').map(Number);console.log('minor ->',a+'.'+(b+1)+'.0');console.log('major ->',(a+1)+'.0.0')" "$BASE" -``` - -Note if local `package.json` differs from `BASE` (an unpublished bump) — surface it, but still base the new version on npm. - -### Step 3 — Ask minor or major - -Use the **AskUserQuestion** tool with the two computed candidates as options (show the resulting version in each label, e.g. "minor → 0.8.0"). Set the new version from the answer. - -### Step 4 — Generate the changelog entry - -```bash -LAST=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null) -git log --no-merges "${LAST}..HEAD" --pretty=format:'%h %s' -``` - -Read the commit subjects; for any whose user impact is unclear, inspect the diff (`git show ` or `git diff "${LAST}..HEAD" -- `). Then **write the entry yourself** following the repo's conventions in `CLAUDE.md` → "Writing changelog entries": - -- Header: `## [X.Y.Z] - YYYY-MM-DD` (get the date with `date +%F`). -- Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — **omit empty sections**. -- Write from the **user's perspective** (observable capability/symptom), not the implementation. Collapse noisy commits ("fix typo", "address review") into the feature they belong to or drop them. -- Plan the bottom link reference: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`. - -Do not write to any file yet — draft it for review first. - -### Step 5 — CONFIRMATION GATE - -Show the user, in chat: -1. The new version (`BASE` → `X.Y.Z`, minor/major). -2. The full drafted changelog entry. -3. The exact actions Steps 6–9 will take (commit + push + npm publish + GitHub release). - -Then **STOP**. Proceed only on explicit approval ("yes" / "proceed"). If the user requests prose changes, revise the draft and re-show. Do not run any command below until approved. - -### Step 6 — Write changelog, bump, build - -1. Use the **Edit** tool to insert the drafted `## [X.Y.Z]` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version), and add the link reference with the other `[x.y.z]:` links at the bottom. -2. Bump (also updates `package-lock.json`; `--allow-same-version` keeps re-runs safe): - ```bash - npm version X.Y.Z --no-git-tag-version --allow-same-version - ``` -3. Build (fail fast before any push/publish): - ```bash - npm run build - ``` - -### Step 7 — Commit + push - -`release.sh` tags HEAD, so the bump must be committed first. - -```bash -git add package.json package-lock.json CHANGELOG.md -git commit -m "release: X.Y.Z" -git push -``` - -### Step 8 — Publish to npm - -```bash -npm publish --access public -``` - -### Step 9 — GitHub release - -`scripts/release.sh` reads the `## [X.Y.Z]` block from CHANGELOG.md, tags `vX.Y.Z`, pushes the tag, and creates the GitHub release. It is idempotent. - -```bash -./scripts/release.sh -``` - -### Step 10 — Verify - -Confirm against the **registry**, not the website (the website caches): - -```bash -npm view "$PKG" version # must equal X.Y.Z -``` - -Report the release URL (`scripts/release.sh` prints it) and the published version. - -## If something fails midway - -Re-running is safe: `npm version --allow-same-version` no-ops if already bumped, `git commit` skips if nothing's staged (check `git diff --cached --quiet`), `git push` no-ops if up to date, and `scripts/release.sh` skips tag/release steps already done. Re-run from the failed step. diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc index 3f23cf6b6..00a3f8152 100644 --- a/.cursor/rules/codegraph.mdc +++ b/.cursor/rules/codegraph.mdc @@ -16,6 +16,7 @@ Use codegraph for **structural** questions — what calls what, what would break | "Where is X defined?" / "Find symbol named X" | `codegraph_search` | | "What calls function Y?" | `codegraph_callers` | | "What does Y call?" | `codegraph_callees` | +| "How does X reach/become Y? / trace the flow from X to Y" | `codegraph_trace` (one call = the whole path, incl. callback/React/JSX dynamic hops) | | "What would break if I changed Z?" | `codegraph_impact` | | "Show me Y's signature / source / docstring" | `codegraph_node` | | "Give me focused context for a task/area" | `codegraph_context` | @@ -25,12 +26,12 @@ Use codegraph for **structural** questions — what calls what, what would break ### Rules of thumb -- **Answer directly — don't delegate exploration.** For "how does X work" / architecture / trace questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer. +- **Answer directly — don't delegate exploration.** For "how does X work" / architecture questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. For a specific **flow** ("how does X reach Y") start with `codegraph_trace` from→to — one call returns the whole path with dynamic hops bridged — then ONE `codegraph_explore` for the bodies; don't rebuild the path with `codegraph_search` + `codegraph_callers`. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer. - **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. - **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. - **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. - **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more. -- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. +- **Index lag — check the staleness banner, don't guess a wait.** When a codegraph response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Files NOT in that banner are fresh and codegraph is authoritative for them. `codegraph_status` also lists pending files under "Pending sync". ### If `.codegraph/` doesn't exist diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 000000000..b66dde951 --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,43 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'site/**' + - '.github/workflows/deploy-site.yml' + workflow_dispatch: + +# Allow GITHUB_TOKEN to deploy to Pages and verify the deployment origin. +permissions: + contents: read + pages: write + id-token: write + +# One deploy at a time; let an in-progress run finish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build with Astro + uses: withastro/action@v3 + with: + path: site + node-version: 22 + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..88bce26a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,204 @@ +name: Release + +# Manually triggered ("Run workflow"). On trigger it: +# 1. reads the version from package.json, +# 2. promotes `## [Unreleased]` content into `## []` in +# CHANGELOG.md (and commits + pushes that change back to main), so +# the published release notes are never sparse just because the +# maintainer didn't pre-stage the [] block by hand, +# 3. builds a self-contained bundle for every platform (one runner — there's no +# native compilation, so cross-packaging is fine), +# 4. creates the GitHub Release (tag v) with all archives, using the +# release notes from CHANGELOG.md, +# 5. publishes the npm thin-installer (shim + per-platform packages). +# +# Before triggering: bump package.json. CHANGELOG.md entries can live under +# `## [Unreleased]` — step 2 takes care of moving them. Set the NPM_TOKEN secret. +on: + workflow_dispatch: {} + +permissions: + contents: write # create the GitHub Release + tag, push the CHANGELOG promote + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + # Default checkout is detached at a SHA; we need an actual branch + # so the CHANGELOG-promote commit knows where to push. + ref: ${{ github.ref }} + # Authenticate as the maintainer (admin), not as github-actions[bot]. + # The "Require PR approval for main branch" ruleset only lets the + # Admin repo role bypass — and GitHub blocks adding the GitHub + # Actions integration to bypass_actors on user-owned (non-org) + # repos with "Actor GitHub Actions integration must be part of + # the ruleset source or owner organization." So the auto-promote + # and auto-sync `git push origin HEAD:main` steps below both fail + # under the default GITHUB_TOKEN. Using a fine-grained PAT owned + # by the admin makes the push go through cleanly. Set the + # RELEASE_PAT secret with: contents:write on this repo, no other + # scopes. Rotate per your token policy; the workflow only runs + # on manual dispatch so the blast radius is small. + token: ${{ secrets.RELEASE_PAT }} + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Sync package-lock.json if version drifted + # When the maintainer bumps the version on package.json only — for + # example via a GitHub web-UI edit — `npm ci` would refuse to run + # with `EUSAGE: npm ci can only install packages when your + # package.json and package-lock.json … are in sync`. This step + # rewrites just the lock-file's version fields (top-level + the + # `packages.""` entry) to match package.json, then auto-commits + # and pushes the result so on-disk truth on `main` stays + # consistent. Idempotent: if the lock file already matches, no + # commit is made. + run: | + set -euo pipefail + PKG_V=$(node -p "require('./package.json').version") + LOCK_V=$(node -p "require('./package-lock.json').version") + if [ "$PKG_V" = "$LOCK_V" ]; then + echo "package-lock.json already at $PKG_V — nothing to sync." + exit 0 + fi + echo "Lock-file version drift: lock=$LOCK_V, package=$PKG_V. Syncing." + # `--package-lock-only` rewrites only the lock file, doesn't + # touch node_modules or actually install anything. Cheap. + npm install --package-lock-only --ignore-scripts + # Sanity: lockfile should now report the package version. + NEW_LOCK_V=$(node -p "require('./package-lock.json').version") + if [ "$NEW_LOCK_V" != "$PKG_V" ]; then + echo "::error::lock-file still at $NEW_LOCK_V after sync attempt; expected $PKG_V"; exit 1 + fi + if git diff --quiet -- package-lock.json; then + echo "lock file unchanged after sync? bailing"; exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package-lock.json + git commit -m "release: sync package-lock.json to ${PKG_V}" -m "[skip ci] Auto-generated by Release workflow." + git push origin "HEAD:${GITHUB_REF#refs/heads/}" + + - run: npm ci + - name: Ensure zip/unzip + run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip + + - name: Resolve version + id: ver + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Promote [Unreleased] → [] in CHANGELOG.md + # Idempotent: a no-op if [Unreleased] is empty OR if the previous + # run already moved everything. Auto-commit + push the change back + # so the version block on main is the source of truth going + # forward (and so subsequent extract-release-notes.mjs calls + # surface the full content even if this run is re-triggered). + run: | + set -euo pipefail + V="${{ steps.ver.outputs.version }}" + before=$(git rev-parse HEAD) + node scripts/prepare-release.mjs "$V" + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG.md unchanged — nothing to commit." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs(changelog): promote [Unreleased] into [${V}]" -m "[skip ci] Auto-generated by Release workflow." + # Push to the branch the workflow was triggered on (main). + git push origin "HEAD:${GITHUB_REF#refs/heads/}" + fi + + - name: Build all platform bundles + run: | + for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do + bash scripts/build-bundle.sh "$t" + done + ls -lh release + + - name: Generate SHA256SUMS + # Published as a release asset; the npm launcher verifies downloaded + # bundles against it (basenames only, so its path.basename match works). + run: | + ( cd release && sha256sum codegraph-* > SHA256SUMS ) + cat release/SHA256SUMS + + - name: Release notes from CHANGELOG.md + # The [] block was guaranteed-populated by the + # "Promote" step above, so the [Unreleased] fallback should + # never be needed in practice. Kept for defense-in-depth. + run: | + V="${{ steps.ver.outputs.version }}" + node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \ + || node scripts/extract-release-notes.mjs Unreleased > notes.md 2>/dev/null || true + if [ ! -s notes.md ]; then + echo "::error::No release notes in CHANGELOG.md for [$V] or [Unreleased]." + exit 1 + fi + echo "----- release notes -----"; cat notes.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="v${{ steps.ver.outputs.version }}" + # Idempotent: create the release once, otherwise (re-run) refresh assets. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" release/codegraph-* release/SHA256SUMS --clobber + else + gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md + fi + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + V="${{ steps.ver.outputs.version }}" + bash scripts/pack-npm.sh "$V" + # Platform packages first, then the main shim (which depends on them). + # Skip any already on the registry so a re-run only fills in gaps. + for dir in release/npm/codegraph-* release/npm/main; do + name=$(node -p "require('./$dir/package.json').name") + if npm view "$name@$V" version >/dev/null 2>&1; then + echo "skip $name@$V (already published)" + else + echo "publishing $name@$V" + ( cd "$dir" && npm publish --access public ) + fi + done + + - name: Verify every package is actually on the registry + run: | + V="${{ steps.ver.outputs.version }}" + # npm publish can print success without persisting; confirm against the + # registry (with retries for propagation) so green means really shipped. + for dir in release/npm/codegraph-* release/npm/main; do + name=$(node -p "require('./$dir/package.json').name") + ok= + for i in 1 2 3 4 5 6; do + if npm view "$name@$V" version >/dev/null 2>&1; then ok=1; break; fi + echo "waiting for $name@$V to appear ($i)…"; sleep 10 + done + [ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; } + echo "verified $name@$V" + done + + - name: Sync packages to npmmirror + # npmmirror/cnpm mirror lazily and frequently never pull the per-platform + # optionalDependencies on their own, so `npm i` there fails with + # "no prebuilt bundle" (issue #303). Nudge a sync now so mirror users get + # the bundle without waiting. Best-effort — the launcher also self-heals + # from GitHub Releases — so a mirror hiccup never fails the release. + continue-on-error: true + run: | + for dir in release/npm/codegraph-* release/npm/main; do + name=$(node -p "require('./$dir/package.json').name") + enc=$(node -p "encodeURIComponent(require('./$dir/package.json').name)") + echo "sync $name" + curl -s -X PUT "https://registry.npmmirror.com/-/package/$enc/syncs" || true + echo + done diff --git a/.gitignore b/.gitignore index 7c154ae1d..da6c8ef6e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,10 @@ npm-debug.log* # Local Claude settings .claude/settings.local.json +.claude/scheduled_tasks.lock + +# Parallels Windows VM SSH/connection config (local machine, see CLAUDE.md) +.parallels # CodeGraph data directories (in test projects) .codegraph/ @@ -49,3 +53,13 @@ test_frameworks test-languages/ nul +release/ + +.antigravitycli/ + +# Local-only: browser-based tmux session launcher (see tmux-web/README.md) +tmux-web/ + +assets/__pycache__/ + +assets/generate-waitlist.py diff --git a/BUNDLING.md b/BUNDLING.md new file mode 100644 index 000000000..dc21ab531 --- /dev/null +++ b/BUNDLING.md @@ -0,0 +1,74 @@ +# Distribution: self-contained bundles + +CodeGraph ships a **vendored Node runtime** alongside the app. Because Node 22.5+ +has a built-in real SQLite (`node:sqlite`, with WAL + FTS5), bundling Node means: + +- **No native build** — `better-sqlite3` is gone, so there are zero native addons + to compile or rebuild. +- **No wasm fallback** — and therefore no more `database is locked` (issue #238). +- **No Node-version dependence** — the app always runs on the bundled Node, + whatever the user has (or doesn't have) installed. + +## What's in a bundle + +Built by [`scripts/build-bundle.sh`](scripts/build-bundle.sh) — one archive per +platform, identical recipe (only the Node download differs): + +``` +codegraph-/ + node | node.exe # official Node runtime for + lib/ + dist/ # compiled app (+ tree-sitter .wasm grammars, schema.sql) + node_modules/ # production deps only (pure JS / wasm — portable) + bin/ + codegraph | codegraph.cmd # launcher → runs the bundled Node with the app +``` + +Targets: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, +`win32-arm64`. Unix targets produce `.tar.gz` (shell launcher); Windows produces +`.zip` (`node.exe` + a `.cmd` launcher). + +```bash +scripts/build-bundle.sh linux-x64 # -> release/codegraph-linux-x64.tar.gz +scripts/build-bundle.sh win32-x64 # -> release/codegraph-win32-x64.zip +``` + +Because dropping better-sqlite3 left **zero native addons**, building a bundle is +pure file-packaging — **any** target builds on **any** OS (the whole matrix builds +on one Linux runner). Cross-compilation isn't a concern; only *run-testing* a +bundle needs the target platform (or emulation, e.g. `docker run --platform +linux/amd64`). + +## Install channels (all deliver the same bundle) + +1. **`curl | sh`** ([`install.sh`](install.sh)) — no Node required; ideal for a + fresh Linux VPS over SSH. Detects os/arch, pulls the archive from GitHub + Releases, symlinks `codegraph` onto PATH. Re-run to upgrade; `--uninstall` to + remove. +2. **npm** ([`scripts/npm-shim.js`](scripts/npm-shim.js)) — preserves + `npm i -g @colbymchenry/codegraph`. The main package is a tiny shim; the + bundles ship as per-platform `optionalDependencies` + (`@colbymchenry/codegraph-` with `os`/`cpu`), so npm installs only the + matching one. The shim — run by the user's Node — execs the bundle, so the + real work runs on the bundled Node 24. Works even on old Node. On Windows it + invokes the bundled `node.exe` against the app entry directly (not the `.cmd` + launcher) — modern Node throws `EINVAL` when asked to spawn a `.cmd`/`.bat`. +3. **Windows** ([`install.ps1`](install.ps1)) — `irm … | iex`; same flow as + install.sh (detect arch, pull the `.zip` from Releases, add to PATH). +4. **Homebrew / Scoop** — TODO (tap + cask pointing at the Release archives). + +## Release pipeline + +[`.github/workflows/release.yml`](.github/workflows/release.yml) — manually +triggered. Reads the version from `package.json`, builds every platform bundle on +one runner, creates the GitHub Release (notes from `CHANGELOG.md`), and publishes +the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret. + +Still TODO: +- **Code signing** — the main gap for "download & run": macOS Gatekeeper needs a + Developer ID + notarization; Windows needs Authenticode. Homebrew softens the + macOS case (handles quarantine). +- Retire the now-vestigial Node-version gate in `src/bin/codegraph.ts` — the + bundle always runs Node 24, and the npm shim does no tree-sitter work. +- Re-wire `npm uninstall` cleanup (the agent-config `preuninstall`) through the + shim — the generated main package doesn't carry it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b924af9e..fbf4c5d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,350 +9,375 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Added -- **Lua**: CodeGraph now indexes Lua (`.lua`) — functions, methods (table `t.f` - and `t:m` definitions become methods with a `t::f` receiver-qualified name), - local variables, `require(...)` imports, and the call edges between them. - Querying a Lua project (Neovim plugins, Kong, OpenResty, game code) now - surfaces its modules, methods, and call graph. -- **Luau** ([#232](https://github.com/colbymchenry/codegraph/issues/232)): - CodeGraph now indexes Luau (`.luau`), Roblox's typed superset of Lua — - everything Lua extracts, plus `type` / `export type` aliases, typed function - signatures, generics, and Roblox instance-path `require(script.Parent.X)` - imports. +### Security + +- Closed a path-traversal hole where a symbolic link inside an indexed project that pointed *outside* the project root could make CodeGraph serve that out-of-root file's contents (for example a file under your home directory) to the AI agent. CodeGraph now resolves symlinks when validating file access and refuses to read anything whose real location is outside the project, while still allowing symlinks that stay within it. Thanks @sulthonzh. (#527) +- CodeGraph now indexes Spring configuration files (`application.properties` / `application.yml`) by key only, and never includes their values in `codegraph_explore` or `codegraph_node` output. Previously a secret committed to one of these files — a database password, API key, or connection string with embedded credentials — could be surfaced to an AI agent that asked about nearby code, even though the agent never opened the file. The configuration keys are still indexed, so reference and impact analysis are unaffected; an agent that genuinely needs a value reads the file itself. Shopify Liquid `{% schema %}` blocks are likewise indexed by name only. (#383) + +### New Features + +- **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore ""` and `codegraph node ` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704) +- **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone. +- **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769) +- **Astro projects are now indexed.** `.astro` files previously weren't parsed at all — on a typical Astro site that left most of the codebase invisible to search, impact, and `codegraph_explore`. CodeGraph now extracts the TypeScript frontmatter (functions, imports, `getStaticPaths`, …) and client-side `\n

Login {data.x}

\n`); + fs.writeFileSync(path.join(login, '+page.server.js'), `export function load() { return { x: 1 }; }\n`); + fs.writeFileSync(path.join(register, '+page.svelte'), `\n

Register

\n`); + fs.writeFileSync(path.join(register, '+page.server.js'), `export function load() { return { y: 2 }; }\n`); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const loginLoad = cg + .getNodesByKind('function') + .find((n) => n.name === 'load' && n.filePath.endsWith('login/+page.server.js')); + expect(loginLoad, 'login load fn').toBeDefined(); + const impacted = [...cg.getImpactRadius(loginLoad!.id, 3).nodes.values()].map((n) => n.filePath ?? ''); + // editing login's load surfaces login's page (the framework-wired data flow)… + expect(impacted.some((p) => p.endsWith('login/+page.svelte')), 'load links to its own page').toBe(true); + // …but never register's page (same-directory only). + expect(impacted.some((p) => p.endsWith('register/+page.svelte')), 'does not cross routes').toBe(false); + }); +}); + +describe('Nuxt nested auto-imported component resolution', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('links a `` usage to components/media/Card.vue (Nuxt dir-prefixed auto-import)', async () => { + // Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name — + // components/media/Card.vue is used as , not — but the + // component node is named by basename (`Card`), so the PascalCase usage + // didn't resolve and the nested component looked unused. + const media = path.join(tempDir, 'components/media'); + fs.mkdirSync(media, { recursive: true }); + fs.writeFileSync(path.join(media, 'Card.vue'), `\n\n`); + fs.writeFileSync( + path.join(tempDir, 'components/Grid.vue'), + `\n\n` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const card = cg.getNodesByKind('component').find((n) => n.filePath.endsWith('media/Card.vue')); + expect(card, 'media/Card.vue component').toBeDefined(); + const deps = [...cg.getImpactRadius(card!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(deps.some((p) => p.endsWith('components/Grid.vue')), ' links Grid to media/Card.vue').toBe(true); + }); +}); + +describe('Swift property-wrapper attribute type references', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('a Fluent `@Siblings(through: Pivot.self)` links the model to the pivot type', async () => { + // A many-to-many pivot/join model is referenced ONLY through the relationship + // property wrapper's metatype argument (`Pivot.self`), never by a controller + // query. The wrapper type was captured but the argument expression wasn't + // walked, so the pivot model looked like nothing depended on it. + fs.writeFileSync(path.join(tempDir, 'Pivot.swift'), + `import Fluent\nfinal class AcronymCategoryPivot: Model {\n static let schema = "acronym-category"\n}\n`); + fs.writeFileSync(path.join(tempDir, 'Acronym.swift'), + `import Fluent\nfinal class Acronym: Model {\n` + + ` @Siblings(through: AcronymCategoryPivot.self, from: \\.$acronym, to: \\.$category)\n` + + ` var categories: [Category]\n}\n`); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const pivot = cg.getNodesByKind('class').find((n) => n.name === 'AcronymCategoryPivot'); + expect(pivot, 'pivot model class').toBeDefined(); + const deps = [...cg.getImpactRadius(pivot!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(deps.some((p) => p.endsWith('Acronym.swift')), '@Siblings metatype arg links Acronym to the pivot').toBe(true); + }); +}); + +describe('Objective-C messages, class receivers, and #import', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('resolves single-arg selectors, class-message receivers, and #import headers', async () => { + fs.writeFileSync( + path.join(tempDir, 'SDImageCache.h'), + `#import +@interface SDImageCache : NSObject ++ (instancetype)sharedCache; ++ (void)storeImage:(NSString *)key; +@end +` + ); + fs.writeFileSync( + path.join(tempDir, 'SDImageCache.m'), + `#import "SDImageCache.h" +@implementation SDImageCache ++ (instancetype)sharedCache { return nil; } ++ (void)storeImage:(NSString *)key { } +@end +` + ); + fs.writeFileSync( + path.join(tempDir, 'SDManager.m'), + `#import "SDImageCache.h" +@interface SDManager : NSObject +@end +@implementation SDManager +- (void)run { + [SDImageCache sharedCache]; + [SDImageCache storeImage:@"k"]; +} +@end +` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + // 1. The single-argument selector `[SDImageCache storeImage:@"k"]` resolves + // to the `storeImage:` method — named WITH its colon both at the call site + // and the definition (before the fix the call site dropped the colon). + const storeImage = cg.getNodesByKind('method').find((n) => n.name === 'storeImage:'); + expect(storeImage, 'storeImage: method').toBeDefined(); + const storeCallers = [...cg.getImpactRadius(storeImage!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(storeCallers.some((p) => p.endsWith('SDManager.m'))).toBe(true); + + // 2. The class-message receiver `[SDImageCache sharedCache]` references the + // SDImageCache class (whose @interface lives in the header). + const cache = cg.getNodesByKind('class').find((n) => n.name === 'SDImageCache'); + expect(cache, 'SDImageCache class').toBeDefined(); + const classDeps = [...cg.getImpactRadius(cache!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(classDeps.some((p) => p.endsWith('SDManager.m'))).toBe(true); + + // 3. `#import "SDImageCache.h"` resolves to the header FILE — editing it + // surfaces both importers. + const header = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('SDImageCache.h')); + expect(header, 'SDImageCache.h indexed').toBeDefined(); + const importers = [...cg.getImpactRadius(header!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(importers.some((p) => p.endsWith('SDManager.m'))).toBe(true); + }); +}); + +describe('Full Indexing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should index a TypeScript file', async () => { + // Create test file + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + fs.writeFileSync( + path.join(srcDir, 'utils.ts'), + ` +export function add(a: number, b: number): number { + return a + b; +} + +export function multiply(a: number, b: number): number { + return a * b; +} +` + ); + + // Initialize and index + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(1); + expect(result.nodesCreated).toBeGreaterThanOrEqual(2); + + // Check nodes were stored + const nodes = cg.getNodesInFile('src/utils.ts'); + expect(nodes.length).toBeGreaterThanOrEqual(2); + + const addFunc = nodes.find((n) => n.name === 'add'); + expect(addFunc).toBeDefined(); + expect(addFunc?.kind).toBe('function'); + + cg.close(); + }); + + it('should index multiple files', async () => { + // Create test files + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir); + + fs.writeFileSync( + path.join(srcDir, 'math.ts'), + `export function add(a: number, b: number) { return a + b; }` + ); + + fs.writeFileSync( + path.join(srcDir, 'string.ts'), `export function capitalize(s: string) { return s.toUpperCase(); }` ); @@ -2955,23 +5043,87 @@ export function multiply(a: number, b: number): number { const cg = CodeGraph.initSync(tempDir); await cg.indexAll(); - const initialNodes = cg.getNodesInFile('src/main.ts'); - expect(initialNodes.some((n) => n.name === 'original')).toBe(true); + const initialNodes = cg.getNodesInFile('src/main.ts'); + expect(initialNodes.some((n) => n.name === 'original')).toBe(true); + + // Modify file + fs.writeFileSync( + path.join(srcDir, 'main.ts'), + `export function updated() { return 2; }` + ); + + // Sync + const syncResult = await cg.sync(); + expect(syncResult.filesModified).toBe(1); + + // Check nodes were updated + const updatedNodes = cg.getNodesInFile('src/main.ts'); + expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true); + expect(updatedNodes.some((n) => n.name === 'original')).toBe(false); + + cg.close(); + }); + + it('should count file-level tracked YAML files as indexed', async () => { + fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n'); + fs.writeFileSync(path.join(tempDir, 'routes.yml'), 'route: value\n'); + + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(2); + expect(result.filesSkipped).toBe(0); + expect(cg.getFiles().map((f) => f.path).sort()).toEqual(['app.yaml', 'routes.yml']); + + cg.close(); + }); + + it('should count file-level tracked YAML/Twig files as indexed in indexFiles()', async () => { + fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n'); + fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n'); + + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexFiles(['app.yaml', 'view.twig']); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(2); + expect(result.filesSkipped).toBe(0); + + const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort(); + expect(tracked).toEqual(['app.yaml:yaml', 'view.twig:twig']); + + cg.close(); + }); + + it('should count file-level tracked .properties files as indexed', async () => { + fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n'); + fs.writeFileSync(path.join(tempDir, 'log.properties'), 'log.level=INFO\n'); + + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(2); + expect(result.filesSkipped).toBe(0); + + cg.close(); + }); - // Modify file - fs.writeFileSync( - path.join(srcDir, 'main.ts'), - `export function updated() { return 2; }` - ); + it('should count the full file-level tracked class (yaml/twig/properties) in indexFiles()', async () => { + fs.writeFileSync(path.join(tempDir, 'app.yaml'), 'name: test\n'); + fs.writeFileSync(path.join(tempDir, 'view.twig'), '{{ title }}\n'); + fs.writeFileSync(path.join(tempDir, 'application.properties'), 'server.port=8080\n'); - // Sync - const syncResult = await cg.sync(); - expect(syncResult.filesModified).toBe(1); + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexFiles(['app.yaml', 'view.twig', 'application.properties']); - // Check nodes were updated - const updatedNodes = cg.getNodesInFile('src/main.ts'); - expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true); - expect(updatedNodes.some((n) => n.name === 'original')).toBe(false); + expect(result.success).toBe(true); + expect(result.filesIndexed).toBe(3); + expect(result.filesSkipped).toBe(0); + + const tracked = cg.getFiles().map((f) => `${f.path}:${f.language}`).sort(); + expect(tracked).toEqual(['app.yaml:yaml', 'application.properties:properties', 'view.twig:twig']); cg.close(); }); @@ -3003,39 +5155,57 @@ describe('Directory Exclusion', () => { cleanupTempDir(tempDir); }); - it('should exclude node_modules directories', () => { - // Create structure: src/index.ts + node_modules/pkg/index.js + it('should exclude directories listed in .gitignore', () => { + // Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules const srcDir = path.join(tempDir, 'src'); const nmDir = path.join(tempDir, 'node_modules', 'pkg'); fs.mkdirSync(srcDir, { recursive: true }); fs.mkdirSync(nmDir, { recursive: true }); fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n'); - const config = { ...DEFAULT_CONFIG, rootDir: tempDir }; - const files = scanDirectory(tempDir, config); + const files = scanDirectory(tempDir); expect(files).toContain('src/index.ts'); expect(files.every((f) => !f.includes('node_modules'))).toBe(true); }); - it('should exclude nested node_modules directories', () => { - // Create structure: packages/app/node_modules/pkg/index.js + it('should exclude nested node_modules via a root .gitignore', () => { + // A trailing-slash pattern with no leading slash matches at any depth. const srcDir = path.join(tempDir, 'packages', 'app', 'src'); const nmDir = path.join(tempDir, 'packages', 'app', 'node_modules', 'pkg'); fs.mkdirSync(srcDir, { recursive: true }); fs.mkdirSync(nmDir, { recursive: true }); fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n'); - const config = { ...DEFAULT_CONFIG, rootDir: tempDir }; - const files = scanDirectory(tempDir, config); + const files = scanDirectory(tempDir); expect(files).toContain('packages/app/src/index.ts'); expect(files.every((f) => !f.includes('node_modules'))).toBe(true); }); - it('should exclude .git directories', () => { + it('should apply a nested .gitignore only to its own subtree', () => { + const appSrc = path.join(tempDir, 'app', 'src'); + fs.mkdirSync(appSrc, { recursive: true }); + fs.writeFileSync(path.join(appSrc, 'keep.ts'), 'export const a = 1;'); + fs.writeFileSync(path.join(appSrc, 'skip.ts'), 'export const b = 2;'); + fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/skip.ts\n'); + // A sibling with the same name outside app/ must NOT be ignored. + const otherDir = path.join(tempDir, 'other', 'src'); + fs.mkdirSync(otherDir, { recursive: true }); + fs.writeFileSync(path.join(otherDir, 'skip.ts'), 'export const c = 3;'); + + const files = scanDirectory(tempDir); + + expect(files).toContain('app/src/keep.ts'); + expect(files).not.toContain('app/src/skip.ts'); + expect(files).toContain('other/src/skip.ts'); + }); + + it('should always skip .git directories', () => { const srcDir = path.join(tempDir, 'src'); const gitDir = path.join(tempDir, '.git', 'objects'); fs.mkdirSync(srcDir, { recursive: true }); @@ -3043,8 +5213,7 @@ describe('Directory Exclusion', () => { fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); fs.writeFileSync(path.join(gitDir, 'pack.ts'), 'export const y = 2;'); - const config = { ...DEFAULT_CONFIG, rootDir: tempDir }; - const files = scanDirectory(tempDir, config); + const files = scanDirectory(tempDir); expect(files).toContain('src/index.ts'); expect(files.every((f) => !f.includes('.git'))).toBe(true); @@ -3055,29 +5224,12 @@ describe('Directory Exclusion', () => { fs.mkdirSync(srcDir, { recursive: true }); fs.writeFileSync(path.join(srcDir, 'Button.tsx'), 'export function Button() {}'); - const config = { ...DEFAULT_CONFIG, rootDir: tempDir }; - const files = scanDirectory(tempDir, config); + const files = scanDirectory(tempDir); expect(files.length).toBe(1); expect(files[0]).toBe('src/components/Button.tsx'); expect(files[0]).not.toContain('\\'); }); - - it('should respect .codegraphignore marker', () => { - const srcDir = path.join(tempDir, 'src'); - const vendorDir = path.join(tempDir, 'vendor'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.mkdirSync(vendorDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); - fs.writeFileSync(path.join(vendorDir, 'lib.ts'), 'export const y = 2;'); - fs.writeFileSync(path.join(vendorDir, '.codegraphignore'), ''); - - const config = { ...DEFAULT_CONFIG, rootDir: tempDir }; - const files = scanDirectory(tempDir, config); - - expect(files).toContain('src/index.ts'); - expect(files.every((f) => !f.includes('vendor'))).toBe(true); - }); }); describe('Git Submodules', () => { @@ -3124,8 +5276,7 @@ describe('Git Submodules', () => { ); git(mainDir, 'commit', '-q', '-m', 'add submodule'); - const config = { ...DEFAULT_CONFIG, rootDir: mainDir }; - const files = scanDirectory(mainDir, config); + const files = scanDirectory(mainDir); expect(files).toContain('app.ts'); expect(files).toContain('libs/lib/lib.ts'); @@ -3173,8 +5324,7 @@ describe('Nested non-submodule git repos', () => { git(path.join(root, 'sub_repo2'), 'init', '-q'); fs.writeFileSync(path.join(sub2, 'two.ts'), 'export const two = 2;'); - const config = { ...DEFAULT_CONFIG, rootDir: root }; - const files = scanDirectory(root, config); + const files = scanDirectory(root); // Both committed and untracked source from the nested repos must be found. expect(files).toContain('sub_repo1/src/one.ts'); @@ -3197,12 +5347,59 @@ describe('Nested non-submodule git repos', () => { fs.writeFileSync(path.join(sub, 'real.ts'), 'export const real = 1;'); fs.writeFileSync(path.join(sub, 'generated.ts'), 'export const generated = 1;'); - const config = { ...DEFAULT_CONFIG, rootDir: root }; - const files = scanDirectory(root, config); + const files = scanDirectory(root); expect(files).toContain('sub_repo/src/real.ts'); expect(files).not.toContain('sub_repo/src/generated.ts'); }); + + // A .gitignore the `ignore` library can't compile to a regex must not abort + // the whole scan — the bad pattern is dropped, valid ones still apply (#682). + it('does not crash on a .gitignore with an uncompilable pattern (#682)', () => { + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'build'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;'); + fs.writeFileSync(path.join(tempDir, 'build', 'out.ts'), 'export const y = 2;'); + // `\\[` makes the matcher build an unterminated character class — the throw + // is lazy (at match time), which is what escaped and killed sync. + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'build/\n\\\\[\n'); + + let files: string[] = []; + expect(() => { + files = scanDirectory(tempDir); + }).not.toThrow(); + expect(files).toContain('src/real.ts'); + // The still-valid `build/` rule is honored; only the bad line was dropped. + expect(files.some((f) => f.startsWith('build/'))).toBe(false); + }); + + // A .gitignore that isn't valid UTF-8 — e.g. encrypted in place by corporate + // DLP / endpoint software (UTF-16 header + ciphertext) — is skipped whole, + // not fed to the matcher as garbage patterns (#682). + it('does not crash on a non-UTF-8 (DLP-encrypted) .gitignore (#682)', () => { + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'real.ts'), 'export const x = 1;'); + const header = Buffer.concat([ + Buffer.from([0x00, 0x00]), + Buffer.from('[notice][user]', 'utf16le'), + ]); + const junk = Buffer.from([0x5b, 0x99, 0xc3, 0x28, 0x5c, 0x5b, 0xff, 0xfd]); + fs.writeFileSync(path.join(tempDir, '.gitignore'), Buffer.concat([header, junk])); + + let files: string[] = []; + expect(() => { + files = scanDirectory(tempDir); + }).not.toThrow(); + expect(files).toContain('src/real.ts'); + }); + + it('buildDefaultIgnore survives a bad .gitignore and still applies valid rules (#682)', () => { + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n\\\\[\n'); + const ig = buildDefaultIgnore(tempDir); + expect(() => ig.ignores('src/app.ts')).not.toThrow(); + expect(ig.ignores('dist/')).toBe(true); // valid rule survives + expect(ig.ignores('src/app.ts')).toBe(false); + }); }); // ============================================================================= @@ -3559,6 +5756,79 @@ function increment(): void { } }); + it('should extract calls from top-level +`; + const result = extractFromSource('Issue425Setup.vue', code); + + const call = result.unresolvedReferences.find( + (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp' + ); + expect(call).toBeDefined(); + }); + + it('should extract calls from Vue Options API object methods', () => { + const code = ` + + +`; + const result = extractFromSource('Issue425Options.vue', code); + + const calls = result.unresolvedReferences.filter( + (ref) => ref.referenceKind === 'calls' && ref.referenceName === 'getTokenMp' + ); + expect(calls).toHaveLength(2); + }); + + it('should extract component usages from the Vue template (PascalCase + kebab, skipping built-ins) (#629)', () => { + const code = ` + + +`; + const result = extractFromSource('Host.vue', code); + const refs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'references') + .map((r) => r.referenceName); + + expect(refs).toContain('UserCard'); // PascalCase tag + expect(refs).toContain('MyButton'); // kebab → MyButton + expect(refs).not.toContain('Transition'); // Vue built-in skipped + expect(refs).not.toContain('Div'); // native HTML element skipped + expect(refs).not.toContain('Span'); + }); + it('should extract from both + +`; + const result = extractFromSource('Guard.astro', code); + + const templateRefs = result.unresolvedReferences.filter( + (r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent' + ); + expect(templateRefs).toHaveLength(0); + + // maybeCall/scriptCall come from the delegated TS extraction (once), + // not double-counted by the template scanner + const maybeCalls = result.unresolvedReferences.filter( + (r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls' + ); + expect(maybeCalls.length).toBeLessThanOrEqual(1); + }); + + it('should extract +`; + const result = extractFromSource('Tracker.astro', code); + + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView'); + expect(fn).toBeDefined(); + expect(fn?.startLine).toBe(6); + expect(fn?.language).toBe('astro'); + }); + + it('should create component node for a frontmatter-less template-only file', () => { + const code = `
Static content
+`; + const result = extractFromSource('Static.astro', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(componentNode?.name).toBe('Static'); + expect(componentNode?.language).toBe('astro'); + }); + + it('should treat an unclosed frontmatter fence as no frontmatter', () => { + const code = `--- +const broken = true; +
never closed
+`; + const result = extractFromSource('Broken.astro', code); + + // No TS delegation happened (the fence never closes), but the component + // node still exists and nothing throws. + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined(); + }); + + it('should create containment edges from component to frontmatter nodes', () => { + const code = `--- +const value = 42; +--- +
{value}
+`; + const result = extractFromSource('Contained.astro', code); + + const componentNode = result.nodes.find((n) => n.kind === 'component'); + expect(componentNode).toBeDefined(); + + const containEdges = result.edges.filter( + (e) => e.source === componentNode!.id && e.kind === 'contains' + ); + expect(containEdges.length).toBeGreaterThan(0); + }); +}); + describe('Instantiates + Decorates edge extraction', () => { it('emits an instantiates ref for `new Foo()`', () => { const code = ` @@ -3899,3 +6381,598 @@ local count = 0 }); }); }); + +// ============================================================================= +// Objective-C +// ============================================================================= + +describe('Objective-C Extraction', () => { + const sample = ` +#import +#import "MyClass.h" + +@interface MyClass : NSObject +@property (nonatomic, copy) NSString *name; +- (void)greet; +- (void)doThing:(id)x with:(id)y; ++ (instancetype)shared; +@end + +@implementation MyClass + +- (void)greet { + NSLog(@"Hello"); + [self doWork]; +} + +- (void)doThing:(id)x with:(id)y { + [self notify:x]; +} + ++ (instancetype)shared { + return [[MyClass alloc] init]; +} + +@end + +void helperFunction(int count) { + MyClass *obj = [MyClass shared]; + [obj greet]; +} +`; + + it('should extract classes, methods, functions, and imports', () => { + const result = extractFromSource('App.m', sample); + + const classes = result.nodes.filter((n) => n.kind === 'class'); + expect(classes.filter((c) => c.name === 'MyClass')).toHaveLength(1); + + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.map((m) => m.name).sort()).toEqual(['doThing:with:', 'greet', 'shared']); + + const shared = methods.find((m) => m.name === 'shared'); + expect(shared?.isStatic).toBe(true); + + const properties = result.nodes.filter((n) => n.kind === 'property'); + expect(properties.some((p) => p.name === 'name')).toBe(true); + + const functions = result.nodes.filter((n) => n.kind === 'function'); + expect(functions.some((f) => f.name === 'helperFunction')).toBe(true); + + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('Foundation/Foundation.h'); + expect(imports).toContain('MyClass.h'); + }); + + it('should record inheritance and protocol conformance', () => { + const result = extractFromSource('App.m', sample); + const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); + const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements'); + expect(extendsRefs.map((r) => r.referenceName)).toContain('NSObject'); + expect(implementsRefs.map((r) => r.referenceName)).toContain('NSCopying'); + }); + + it('should record message sends and C calls', () => { + const result = extractFromSource('App.m', sample); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toEqual(expect.arrayContaining(['NSLog', 'doWork', 'MyClass.shared', 'obj.greet'])); + }); + + it('should reconstruct multi-keyword selectors at the call site so they resolve to the method definition', () => { + // Regression for the gap discovered post-#165: message_expression's + // multi-keyword form `[obj a:1 b:2]` was only emitting the first keyword, + // so calls never resolved to multi-part method definitions like + // `GET:parameters:headers:progress:success:failure:`. The call-site name + // must match the method-definition name with full keywords + trailing colons. + const code = ` +@implementation Caller +- (void)demo { + NSMutableDictionary *d = [NSMutableDictionary new]; + [d setObject:@"v" forKey:@"k"]; + [d setObject:@"v2" forKey:@"k2" withRetry:@YES]; + [self touchesBegan:nil withEvent:nil]; +} +@end +`; + const result = extractFromSource('Caller.m', code); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(calls).toEqual( + expect.arrayContaining([ + 'd.setObject:forKey:', + 'd.setObject:forKey:withRetry:', + 'touchesBegan:withEvent:', + ]) + ); + }); + + it('should not classify pure C headers with @end in comments as objc', () => { + const cHeader = '/* @end of file */\n#ifndef STDIO_H\nvoid printf(const char *);\n#endif\n'; + expect(detectLanguage('stdio.h', cHeader)).toBe('c'); + }); + + it('should extract protocol declarations', () => { + const code = ` +@protocol DataSource +- (NSInteger)numberOfItems; +@end +`; + const result = extractFromSource('DataSource.h', code); + const protocol = result.nodes.find((n) => n.kind === 'protocol' && n.name === 'DataSource'); + expect(protocol).toBeDefined(); + }); + + it('should report Objective-C as supported', () => { + expect(isLanguageSupported('objc')).toBe(true); + expect(getSupportedLanguages()).toContain('objc'); + }); +}); + +describe('Regression: issue-specific extraction fixes', () => { + it('indexes inner functions of an anonymous AMD/CommonJS module wrapper (#528)', () => { + const code = ` +define(['dep'], function (dep) { + function innerHelper(x) { return x + 1; } + function compute(y) { return innerHelper(y); } + return { compute: compute }; +}); +`; + const result = extractFromSource('amd-module.js', code); + const fns = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fns).toContain('innerHelper'); + expect(fns).toContain('compute'); + }); + + it('attaches Go methods on generic receivers to their type (#583)', () => { + const code = ` +package main + +type Stack[T any] struct { items []T } + +func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) } +func (s Stack[T]) Len() int { return len(s.items) } +`; + const result = extractFromSource('stack.go', code); + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.find((m) => m.name === 'Push')?.qualifiedName).toBe('Stack::Push'); + expect(methods.find((m) => m.name === 'Len')?.qualifiedName).toBe('Stack::Len'); + }); + + it('indexes new module extensions: .mts/.cts (TS) and .xsjs/.xsjslib (JS) (#366, #556)', () => { + expect(isSourceFile('mod.mts')).toBe(true); + expect(isSourceFile('mod.cts')).toBe(true); + expect(isSourceFile('service.xsjs')).toBe(true); + expect(isSourceFile('lib.xsjslib')).toBe(true); + expect(detectLanguage('mod.mts')).toBe('typescript'); + expect(detectLanguage('service.xsjs')).toBe('javascript'); + + // End-to-end: a .mts file is parsed as TS, a .xsjs file as JS. + const ts = extractFromSource('mod.mts', 'export function hello(): number { return 1; }'); + expect(ts.nodes.find((n) => n.name === 'hello' && n.kind === 'function')).toBeDefined(); + const js = extractFromSource('service.xsjs', 'function handleRequest() { return 1; }'); + expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined(); + }); +}); + +describe('Import / re-export dependency linking (blast-radius recall)', () => { + // An import IS a dependency, but extraction only emits references for calls, + // instantiations, type annotations, and inheritance — so a symbol imported and + // then merely re-exported, placed in a registry array, passed as an argument, + // or used in JSX produced no cross-file edge, leaving the providing file with a + // false "0 dependents". These tests pin the import/re-export binding linking. + it('emits an imports reference per named, aliased, and default import binding', () => { + const code = ` +import { widget, helper as h } from './foo'; +import Thing from './thing'; +import * as NS from './ns'; +export const registry = [widget]; +`; + const result = extractFromSource('bar.ts', code); + const names = result.unresolvedReferences + .filter((r) => r.referenceKind === 'imports') + .map((r) => r.referenceName); + expect(names).toContain('widget'); // named import → local name + expect(names).toContain('h'); // aliased import → local alias + expect(names).toContain('Thing'); // default import + expect(names).toContain('NS'); // namespace import → linked to the module file as a dependency + }); + + it('emits an imports reference per re-exported binding', () => { + const result = extractFromSource('barrel.ts', `export { alpha, beta as b } from './source';`); + const names = result.unresolvedReferences + .filter((r) => r.referenceKind === 'imports') + .map((r) => r.referenceName); + // Re-export links the SOURCE-side name, not the local alias. + expect(names).toContain('alpha'); + expect(names).toContain('beta'); + }); + + it('a value imported/re-exported but never called still makes the importer a dependent', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'foo.ts'), + `export const widget = { n: 1 };\nexport function helper(): void {}\n` + ); + // bar uses widget ONLY in an array and re-exports helper — neither is + // called/typed, so before import-linking bar had no edge to foo at all. + fs.writeFileSync( + path.join(dir, 'src', 'bar.ts'), + `import { widget } from './foo';\nexport { helper } from './foo';\nexport const registry = [widget];\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('a namespace import touched only via a value-member read still links the module file', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export const SOME_CONST = 42;\n`); + // `foo` is imported as a namespace and used ONLY via a value-member read + // (no call, no type) — `foo.helper()` would link on its own, but a bare + // `foo.SOME_CONST` would not, so the module-import backstop must link it. + fs.writeFileSync(path.join(dir, 'src', 'bar.ts'), `import * as foo from './foo';\nexport const x = foo.SOME_CONST;\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('Python import dependency linking (blast-radius recall)', () => { + // Same recall gap as TS: Python only linked called/instantiated imports, so a + // name brought in with `from module import X` and then merely stored, used as + // a decorator/argument, or re-exported through an `__init__.py` produced no + // cross-file edge — the providing module showed a false "0 dependents". + it('emits an imports reference per name in a `from module import ...` (incl. value/aliased)', () => { + const code = [ + 'from foo import helper, widget', + 'from foo import Thing as T', + 'from . import sibling', + 'from bar import *', + ].join('\n'); + const names = extractFromSource('mod.py', code) + .unresolvedReferences.filter((r) => r.referenceKind === 'imports') + .map((r) => r.referenceName); + expect(names).toContain('helper'); + expect(names).toContain('widget'); // value import + expect(names).toContain('T'); // aliased import → local name + expect(names).toContain('sibling'); // `from . import ` + expect(names).not.toContain('*'); // wildcard import has no names + }); + + it('a Python value imported but never called still makes the importer a dependent', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'pkg', 'foo.py'), `widget = {"n": 1}\ndef helper():\n return 1\n`); + // bar imports widget+helper but only stores widget in a list — nothing is + // called, so before import-linking bar had no edge to foo. + fs.writeFileSync(path.join(dir, 'pkg', 'bar.py'), `from foo import widget, helper\nregistry = [widget]\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('pkg/foo.py')).toContain('pkg/bar.py'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('resolves `from . import submodule` + `submodule.func()` to the submodule', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), ''); + fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `def where():\n return "/ca.pem"\n`); + // certs is an imported MODULE (a file), and certs.where() is a qualified + // call through it — the receiver isn't a symbol, so plain name-matching + // can't link it. Also exercises the Python relative-dot path fix (`.certs`). + fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\ndef go():\n return certs.where()\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('a module import is a dependency even when the used member is re-exported elsewhere', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), ''); + // `where` is NOT defined in certs.py (re-exported from a 3rd-party pkg), so + // member resolution can't find it — the module-import backstop must still + // record utils -> certs. (Mirrors requests' real `certs.where`.) + fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `from external_ca import where\n`); + fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\nCA = certs.where()\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('Go cross-package composite literals (blast-radius recall)', () => { + // Go function calls and type references across packages already resolved, but + // struct composite literals — `render.XML{...}` / `pkga.Widget{...}` — were not + // extracted at all, so a package whose types are only INSTANTIATED elsewhere + // (gin's render/binding implementations) showed 0 dependents. + it('links a cross-package struct composite literal to the defining package', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.mkdirSync(path.join(dir, 'render'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct { Data any }\n`); + fs.writeFileSync(path.join(dir, 'app.go'), `package main\n\nimport "example.com/proj/render"\n\nfunc handle() any { return render.XML{} }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('render/xml.go')).toContain('app.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('links a composite literal in a package-level var registry', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.mkdirSync(path.join(dir, 'render'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct {}\nfunc (XML) Render() {}\n`); + // The implementation is registered only in a top-level `var registry = {...}` + // map literal — the body walker doesn't cover top-level declarations, so this + // exercises the var-initializer walking added for Go. + fs.writeFileSync(path.join(dir, 'reg.go'), `package main\n\nimport "example.com/proj/render"\n\ntype R interface { Render() }\n\nvar registry = map[string]R{ "xml": render.XML{} }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('render/xml.go')).toContain('reg.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('attributes a call inside a top-level closure (cobra RunE) to the var, not the file (#693)', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + // Wire is called ONLY from the anonymous RunE closure inside a top-level + // `var rootCmd = &Cmd{...}` — previously the call leaked to the file node, + // so `callers(Wire)` surfaced a file (or read as "no caller"). It must now + // attribute to the enclosing var. + fs.writeFileSync(path.join(dir, 'factory.go'), `package main\n\nfunc Wire() error { return nil }\n`); + fs.writeFileSync( + path.join(dir, 'root.go'), + `package main\n\ntype Cmd struct{ RunE func() error }\n\nvar rootCmd = &Cmd{\n\tRunE: func() error { return Wire() },\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + + const wire = cg.getNodesByName('Wire').find((n) => n.kind === 'function'); + expect(wire).toBeDefined(); + const callers = cg.getCallers(wire!.id).map((c) => c.node); + expect(callers.some((n) => n.kind === 'variable' && n.name === 'rootCmd')).toBe(true); + expect(callers.some((n) => n.kind === 'file')).toBe(false); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.writeFileSync(path.join(dir, 'types.go'), `package main\n\ntype Wrapped struct { N int }\n`); + // `(*Wrapped)(x)` parses as a call whose callee is the parenthesized type + // `(*Wrapped)` — without normalization it dropped on the floor. + fs.writeFileSync(path.join(dir, 'use.go'), `package main\n\nfunc run(x *int) { _ = (*Wrapped)(x) }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('types.go')).toContain('use.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + + it('links an implementation reached only through a Go interface (implicit satisfaction, #584)', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n'); + fs.mkdirSync(path.join(dir, 'codec'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'codec', 'api.go'), `package codec\n\ntype Core interface {\n\tMarshal(v any) ([]byte, error)\n}\n\nvar API Core\n`); + // jsonApi satisfies Core structurally (no `implements` keyword) and is + // reached ONLY through the interface (API.Marshal). Without implicit + // interface satisfaction + dispatch, json.go shows 0 dependents. + fs.writeFileSync(path.join(dir, 'codec', 'json.go'), `package codec\n\ntype jsonApi struct{}\n\nfunc (j jsonApi) Marshal(v any) ([]byte, error) { return nil, nil }\n\nfunc init() { API = jsonApi{} }\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('codec/json.go')).toContain('codec/api.go'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('C# records (blast-radius recall)', () => { + // Records are ubiquitous in modern C# (DTOs, value objects, CQRS messages), + // but `record` / `record struct` declarations weren't extracted as types — so + // every reference, generic-type-argument, and `new` of a record dropped on the + // floor and the defining file showed 0 dependents. (#237) + it('extracts a record as a graph node (record class + record struct)', () => { + const r = extractFromSource('r.cs', `namespace P;\npublic record Box(int N);\npublic record struct Pt(int X);\n`); + expect(r.nodes.find((n) => n.name === 'Box' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined(); + expect(r.nodes.find((n) => n.name === 'Pt' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined(); + }); + + it('resolves references / instantiations of a record across files', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'types.cs'), `namespace P;\npublic record Box(int N);\n`); + // Box is used as a generic type argument and instantiated — both require + // Box to be a node to resolve. + fs.writeFileSync( + path.join(dir, 'use.cs'), + `using System.Collections.Generic;\nnamespace P;\npublic class User {\n public IEnumerable Boxes { get; }\n public Box Make() => new Box(1);\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.cs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('types.cs')).toContain('use.cs'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); +}); + +describe('Rust cross-module recall', () => { + function rustProject(files: Record): string { + const dir = createTempDir(); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[package]\nname = "proj"\nversion = "0.1.0"\nedition = "2021"\n'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, 'src', rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return dir; + } + + it('extracts a struct literal `Foo { .. }` as an instantiation across modules', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod types;\npub mod consumer;\n', + 'types.rs': 'pub struct Widget { pub n: i32 }\n', + 'consumer.rs': 'use crate::types::Widget;\npub fn build() -> Widget { Widget { n: 1 } }\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); + + it('extracts trait method declarations and bridges trait dispatch to the impl', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod types;\npub mod consumer;\n', + 'types.rs': 'pub trait Render { fn render(&self) -> i32; }\n', + // Mine implements Render structurally; reached via &dyn Render dispatch. + 'consumer.rs': 'use crate::types::Render;\npub struct Mine { pub x: i32 }\nimpl Render for Mine { fn render(&self) -> i32 { self.x } }\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + // implements edge (Mine -> Render) makes types.rs a dependent of consumer.rs's struct. + expect(cg.getFileDependents('src/types.rs')).toContain('src/consumer.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); + + it('links `pub use` re-export hubs to the modules they re-export', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod api;\n', + 'api/mod.rs': 'mod widget;\npub use self::widget::Widget;\n', + 'api/widget.rs': 'pub struct Widget { pub n: i32 }\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + // The re-export hub depends on the module it re-exports from. + expect(cg.getFileDependents('src/api/widget.rs')).toContain('src/api/mod.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); + + it('resolves a qualified path to the correct module when the leaf name collides', async () => { + const dir = rustProject({ + 'lib.rs': 'pub mod fast;\npub mod slow;\npub mod hub;\n', + 'fast.rs': 'pub fn read() -> i32 { 1 }\n', + 'slow.rs': 'pub fn read() -> i32 { 2 }\n', + // `read` exists in BOTH fast.rs and slow.rs — module-path resolution must + // send this re-export to fast.rs specifically, not name-match either. + 'hub.rs': 'pub use crate::fast::read;\n', + }); + try { + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.rs'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/fast.rs')).toContain('src/hub.rs'); + expect(cg.getFileDependents('src/slow.rs')).not.toContain('src/hub.rs'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Java annotations (blast-radius recall)', () => { + it('indexes @interface definitions and links @Annotation usages to them', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'p'), { recursive: true }); + // The annotation DEFINITION must be a node, and the @MyAnno usages (which + // live inside a `modifiers` node on the class/field/method) must extract. + fs.writeFileSync(path.join(dir, 'p', 'MyAnno.java'), `package p;\npublic @interface MyAnno { String value() default ""; }\n`); + fs.writeFileSync( + path.join(dir, 'p', 'User.java'), + `package p;\n@MyAnno("c")\npublic class User {\n @MyAnno("f") int field;\n @MyAnno("m") void go() {}\n}\n` + ); + const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.java'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('p/MyAnno.java')).toContain('p/User.java'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); + +describe('Swift property wrappers / attributes (blast-radius recall)', () => { + it('links a @propertyWrapper usage to the wrapper type', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'Sources', 'M'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Wrap.swift'), `@propertyWrapper\npublic struct Argument { public var wrappedValue: T }\n`); + // `@Argument` is a Swift attribute on a stored property — it lives in the + // property's `modifiers` and Swift doesn't extract instance properties as + // their own nodes, so without the fix the wrapper type has no users. + fs.writeFileSync(path.join(dir, 'Sources', 'M', 'Cmd.swift'), `public struct MyCommand {\n @Argument var name: String\n @Argument var count: Int\n}\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['Sources/**/*.swift'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('Sources/M/Wrap.swift')).toContain('Sources/M/Cmd.swift'); + cg.destroy(); + } finally { cleanupTempDir(dir); } + }); +}); diff --git a/__tests__/fabric-view.test.ts b/__tests__/fabric-view.test.ts new file mode 100644 index 000000000..84e652940 --- /dev/null +++ b/__tests__/fabric-view.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; +import { fabricViewResolver } from '../src/resolution/frameworks/fabric'; + +describe('Fabric view component extractor (codegenNativeComponent specs)', () => { + it('extracts a component node + prop nodes from a Native*.ts spec', () => { + const source = ` +'use client'; +import { codegenNativeComponent } from 'react-native'; +import type { ViewProps, CodegenTypes as CT, ColorValue } from 'react-native'; + +type TapEvent = Readonly<{ x: number; y: number }>; + +export interface NativeProps extends ViewProps { + color?: ColorValue; + onTap?: CT.DirectEventHandler; + caption?: string; +} + +export default codegenNativeComponent('MyView', {}); +`; + const result = fabricViewResolver.extract?.('src/MyViewNativeComponent.ts', source); + expect(result).toBeDefined(); + const componentNodes = result!.nodes.filter((n) => n.kind === 'component'); + const propNodes = result!.nodes.filter((n) => n.kind === 'property'); + expect(componentNodes).toHaveLength(1); + expect(componentNodes[0]?.name).toBe('MyView'); + expect(propNodes.map((n) => n.name).sort()).toEqual(['caption', 'color', 'onTap']); + }); + + it('returns nothing for a file without codegenNativeComponent', () => { + const source = `export const x = 1;`; + const result = fabricViewResolver.extract?.('plain.ts', source); + expect(result?.nodes).toHaveLength(0); + }); + + it('handles a spec with no NativeProps interface (rare but valid)', () => { + const source = ` +import { codegenNativeComponent } from 'react-native'; +export default codegenNativeComponent('BareComponent'); +`; + const result = fabricViewResolver.extract?.('Bare.ts', source); + // Component node exists; no prop nodes. + const components = result!.nodes.filter((n) => n.kind === 'component'); + const props = result!.nodes.filter((n) => n.kind === 'property'); + expect(components).toHaveLength(1); + expect(components[0]?.name).toBe('BareComponent'); + expect(props).toHaveLength(0); + }); +}); + +describe('Fabric end-to-end: JSX consumer → Fabric component → native class', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-fixture-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('connects JSX to the native ObjC class via Fabric synthesizer', async () => { + fs.writeFileSync( + path.join(dir, 'package.json'), + '{"dependencies":{"react-native":"^0.73"}}' + ); + // Fabric spec. + fs.mkdirSync(path.join(dir, 'spec')); + fs.writeFileSync( + path.join(dir, 'spec', 'MyViewNativeComponent.ts'), + `import { codegenNativeComponent } from 'react-native'; +import type { ViewProps } from 'react-native'; +export interface NativeProps extends ViewProps { color?: string; } +export default codegenNativeComponent('MyView');` + ); + // Native iOS implementation — class named with the `View` suffix + // convention. + fs.mkdirSync(path.join(dir, 'ios')); + fs.writeFileSync( + path.join(dir, 'ios', 'MyView.mm'), + `@interface MyViewView : UIView +@end +@implementation MyViewView +- (void)setColor:(NSString *)c { /* … */ } +@end` + ); + // JSX consumer. + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'App.tsx'), + `import React from 'react'; +import MyView from '../spec/MyViewNativeComponent'; +export function App() { + return ; +}` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // 1. The Fabric component node exists. + const componentRows = db + .prepare("SELECT id, name, kind FROM nodes WHERE id LIKE 'fabric-component:%' AND name='MyView'") + .all(); + expect(componentRows).toHaveLength(1); + + // 2. The native class node exists. + const nativeRows = db + .prepare("SELECT id, name FROM nodes WHERE kind='class' AND language='objc' AND name='MyViewView'") + .all(); + expect(nativeRows).toHaveLength(1); + + // 3. Fabric synthesizer bridges component → native class. + const bridgeRows = db + .prepare( + `SELECT s.name comp, t.name native FROM edges e + JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target + WHERE json_extract(e.metadata,'$.synthesizedBy')='fabric-native-impl' + AND s.name='MyView' AND t.name='MyViewView'` + ) + .all(); + expect(bridgeRows).toHaveLength(1); + + // 4. JSX synthesizer links the App function → the Fabric component + // (jsx-render edge keyed on the tag name 'MyView'). + const jsxRows = db + .prepare( + `SELECT s.name caller, t.name comp FROM edges e + JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target + WHERE json_extract(e.metadata,'$.synthesizedBy')='jsx-render' + AND t.id LIKE 'fabric-component:%' AND t.name='MyView'` + ) + .all(); + cg.close?.(); + expect(jsxRows.length).toBeGreaterThanOrEqual(1); + expect(jsxRows[0].caller).toBe('App'); + // The full flow: App (TSX) → MyView (fabric-component) → MyViewView (ObjC native class) + }); +}); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 4e8f204ae..05fa79804 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -9,9 +9,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; -import { DEFAULT_CONFIG, Node, Edge } from '../src/types'; -import { loadConfig, saveConfig } from '../src/config'; -import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; +import { Node, Edge } from '../src/types'; +import { isInitialized, getCodeGraphDir, validateDirectory, codeGraphDirName, isCodeGraphDataDir } from '../src/directory'; import { DatabaseConnection, getDatabasePath } from '../src/db'; // Create a temporary directory for each test @@ -55,21 +54,10 @@ describe('CodeGraph Foundation', () => { expect(fs.existsSync(gitignorePath)).toBe(true); const content = fs.readFileSync(gitignorePath, 'utf-8'); - expect(content).toContain('*.db'); - - cg.close(); - }); - - it('should create config.json with defaults', () => { - const cg = CodeGraph.initSync(tempDir); - - const configPath = path.join(getCodeGraphDir(tempDir), 'config.json'); - expect(fs.existsSync(configPath)).toBe(true); - - const config = cg.getConfig(); - expect(config.version).toBe(DEFAULT_CONFIG.version); - expect(config.include).toEqual(DEFAULT_CONFIG.include); - expect(config.exclude).toEqual(DEFAULT_CONFIG.exclude); + // Ignore everything in .codegraph/ except this file itself, so transient + // files (db, daemon.pid, sockets, logs) never show up in git. (#492, #484) + expect(content).toContain('*'); + expect(content).toContain('!.gitignore'); cg.close(); }); @@ -80,21 +68,6 @@ describe('CodeGraph Foundation', () => { expect(() => CodeGraph.initSync(tempDir)).toThrow(/already initialized/i); }); - - it('should accept custom config options', () => { - const cg = CodeGraph.initSync(tempDir, { - config: { - maxFileSize: 500000, - extractDocstrings: false, - }, - }); - - const config = cg.getConfig(); - expect(config.maxFileSize).toBe(500000); - expect(config.extractDocstrings).toBe(false); - - cg.close(); - }); }); describe('Opening Projects', () => { @@ -112,17 +85,6 @@ describe('CodeGraph Foundation', () => { it('should throw if not initialized', () => { expect(() => CodeGraph.openSync(tempDir)).toThrow(/not initialized/i); }); - - it('should preserve configuration across open/close', () => { - const cg1 = CodeGraph.initSync(tempDir, { - config: { maxFileSize: 123456 }, - }); - cg1.close(); - - const cg2 = CodeGraph.openSync(tempDir); - expect(cg2.getConfig().maxFileSize).toBe(123456); - cg2.close(); - }); }); describe('Static Methods', () => { @@ -182,31 +144,6 @@ describe('CodeGraph Foundation', () => { }); }); - describe('Configuration', () => { - it('should load and merge config with defaults', () => { - const cg = CodeGraph.initSync(tempDir); - cg.close(); - - const config = loadConfig(tempDir); - expect(config.version).toBe(DEFAULT_CONFIG.version); - expect(config.rootDir).toBe(path.resolve(tempDir)); - }); - - it('should update configuration', () => { - const cg = CodeGraph.initSync(tempDir); - - cg.updateConfig({ maxFileSize: 999999 }); - - expect(cg.getConfig().maxFileSize).toBe(999999); - - cg.close(); - - // Verify persistence - const config = loadConfig(tempDir); - expect(config.maxFileSize).toBe(999999); - }); - }); - describe('Directory Management', () => { it('should validate directory structure', () => { const cg = CodeGraph.initSync(tempDir); @@ -222,6 +159,46 @@ describe('CodeGraph Foundation', () => { expect(validation.valid).toBe(false); expect(validation.errors.length).toBeGreaterThan(0); }); + + it('upgrades a stale pre-wildcard .gitignore in place (issue #788)', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // A .gitignore written by an older version (<= 0.9.9): an explicit + // allowlist that never ignored daemon.pid, so the daemon's runtime + // pidfile got committed. + const staleV099 = + '# CodeGraph data files\n' + + '# These are local to each machine and should not be committed\n\n' + + '# Database\n*.db\n*.db-wal\n*.db-shm\n\n' + + '# Cache\ncache/\n\n# Logs\n*.log\n\n# Hook markers\n.dirty\n'; + fs.writeFileSync(gitignorePath, staleV099, 'utf-8'); + + // Opening the project runs validateDirectory, which self-heals. + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + const upgraded = fs.readFileSync(gitignorePath, 'utf-8'); + expect(upgraded).toContain('\n*\n'); // wildcard ignores everything… + expect(upgraded).toContain('!.gitignore'); // …except this file + expect(upgraded).not.toContain('.dirty'); // old explicit list is gone + }); + + it('leaves a user-customized .codegraph/.gitignore untouched', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // No CodeGraph header → user-authored → must not be rewritten. + const custom = '# my own rules\n*.db\n!keep-this.json\n'; + fs.writeFileSync(gitignorePath, custom, 'utf-8'); + + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(custom); + }); }); describe('Uninitialize', () => { @@ -305,7 +282,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(4); + expect(version?.version).toBe(5); db.close(); }); @@ -369,3 +346,93 @@ describe('Query Builder', () => { expect(files).toEqual([]); }); }); + +// Two environments that share one working tree (Windows-native + WSL) must not +// share one `.codegraph/`. CODEGRAPH_DIR overrides the data directory name so +// each side keeps its own index in the same tree (issue #636). +describe('CODEGRAPH_DIR override (#636)', () => { + const saved = process.env.CODEGRAPH_DIR; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-dirname-')); + }); + afterEach(() => { + if (saved === undefined) delete process.env.CODEGRAPH_DIR; + else process.env.CODEGRAPH_DIR = saved; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('codeGraphDirName()', () => { + it('defaults to .codegraph when unset', () => { + delete process.env.CODEGRAPH_DIR; + expect(codeGraphDirName()).toBe('.codegraph'); + }); + + it('honors a valid override', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(codeGraphDirName()).toBe('.codegraph-win'); + }); + + // Anything that isn't a plain segment could escape the project root or + // clobber it, so it's ignored in favor of the default. + it.each(['foo/bar', 'a\\b', '..', '../x', '.', '/abs/path', ' ', ''])( + 'falls back to .codegraph for invalid value %j', + (bad) => { + process.env.CODEGRAPH_DIR = bad; + expect(codeGraphDirName()).toBe('.codegraph'); + } + ); + }); + + describe('isCodeGraphDataDir()', () => { + it('matches the default, the active override, and .codegraph-* siblings', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(isCodeGraphDataDir('.codegraph')).toBe(true); // the other env's dir + expect(isCodeGraphDataDir('.codegraph-win')).toBe(true); // active override + expect(isCodeGraphDataDir('.codegraph-wsl')).toBe(true); // any sibling + }); + + it('does not match unrelated directories', () => { + delete process.env.CODEGRAPH_DIR; + for (const name of ['src', 'node_modules', '.git', 'codegraph', '.codegraphextra']) { + expect(isCodeGraphDataDir(name)).toBe(false); + } + }); + }); + + it('init writes the index under the overridden directory, not .codegraph', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + const cg = CodeGraph.initSync(tempDir); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph'))).toBe(false); + expect(getCodeGraphDir(tempDir)).toBe(path.join(tempDir, '.codegraph-win')); + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + } finally { + cg.close(); + } + }); + + it('two index dirs coexist in one tree and the override side skips the sibling', async () => { + // WSL side: default `.codegraph`, with a source file. + delete process.env.CODEGRAPH_DIR; + fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function onlyReal() {}\n'); + const wsl = await CodeGraph.init(tempDir, { index: true }); + wsl.close(); + + // Windows side: override dir, same tree. Plant a decoy source file INSIDE + // the WSL data dir — the override-side index must not pick it up. + process.env.CODEGRAPH_DIR = '.codegraph-win'; + fs.writeFileSync(path.join(tempDir, '.codegraph', 'decoy.ts'), 'export function decoyLeak() {}\n'); + const win = await CodeGraph.init(tempDir, { index: true }); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(win.searchNodes('onlyReal').length).toBeGreaterThan(0); + expect(win.searchNodes('decoyLeak')).toEqual([]); // sibling data dir not indexed + } finally { + win.close(); + } + }); +}); diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index b64e8c662..344a0f6c9 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -57,3 +57,854 @@ describe('Django end-to-end framework extraction', () => { cg.close(); }); }); + +describe('Flask end-to-end framework extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves stacked routes across @login_required to a view named after a builtin (index)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flask-')); + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'flask==3.0\n'); + fs.writeFileSync( + path.join(tmpDir, 'app.py'), + 'from flask import Blueprint, render_template\n' + + 'from flask_login import login_required\n' + + 'bp = Blueprint("main", __name__)\n' + + '\n' + + '@bp.route("/", methods=["GET", "POST"])\n' + + '@bp.route("/index", methods=["GET", "POST"])\n' + + '@login_required\n' + + 'def index():\n' + + ' return render_template("index.html")\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Both stacked @bp.route decorators are extracted (the second was previously + // dropped because @login_required broke the "def must follow" assumption). + const routes = cg.getNodesByKind('route'); + expect(routes.map((r) => r.name).sort()).toEqual(['GET /', 'GET /index']); + + // The view function exists even though its name is a Python builtin method. + const fn = cg.getNodesByKind('function').find((n) => n.name === 'index'); + expect(fn).toBeDefined(); + + // Both routes resolve to it — exercises the bare-name builtin guard, which + // previously filtered the `index` reference as a builtin method. + for (const route of routes) { + const edges = cg.getOutgoingEdges(route.id); + const toView = edges.find((e) => e.target === fn!.id && e.kind === 'references'); + expect(toView, `route ${route.name} should resolve to index()`).toBeDefined(); + } + + cg.close(); + }); +}); + +describe('Flutter end-to-end — setState→build synthesis', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('synthesizes a handler→build edge when a State method calls setState', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flutter-')); + fs.writeFileSync( + path.join(tmpDir, 'main.dart'), + 'import "package:flutter/material.dart";\n' + + 'class CounterPage extends StatefulWidget {\n' + + ' @override\n' + + ' State createState() => _CounterPageState();\n' + + '}\n' + + 'class _CounterPageState extends State {\n' + + ' int _count = 0;\n' + + ' void _increment() {\n' + + ' setState(() {\n' + + ' _count++;\n' + + ' });\n' + + ' }\n' + + ' @override\n' + + ' Widget build(BuildContext context) {\n' + + ' return Text("$_count");\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const increment = methods.find((n) => n.name === '_increment'); + const build = methods.find((n) => n.name === 'build'); + expect(increment).toBeDefined(); + expect(build).toBeDefined(); + + // setState re-runs build (Flutter-internal, no static edge). The synthesizer + // bridges the handler → build so the "tap → setState → rebuilt UI" flow connects. + const edges = cg.getOutgoingEdges(increment!.id); + const toBuild = edges.find((e) => e.target === build!.id && e.kind === 'calls'); + expect(toBuild, '_increment should reach build via setState synthesis').toBeDefined(); + + cg.close(); + }); +}); + +describe('C++ end-to-end — virtual override synthesis', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves callers through typed object pointers', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'detect.hpp'), + 'class CDetect {\n' + + ' public:\n' + + ' int Processing();\n' + + '};\n' + + 'class CDetector {\n' + + ' private:\n' + + ' CDetect* m_cpAlg = nullptr;\n' + + ' public:\n' + + ' int Run();\n' + + ' int Flush();\n' + + '};\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'detect.cpp'), + '#include "detect.hpp"\n' + + 'int CDetector::Run() { return m_cpAlg->Processing(); }\n' + + 'int CDetector::Flush() { return m_cpAlg->Processing(); }\n' + + 'int CDetect::Processing() { return 0; }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const processing = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('CDetect::Processing')); + expect(processing).toBeDefined(); + + const callers = cg.getCallers(processing!.id).map((c) => c.node.qualifiedName); + expect(callers).toContain('CDetector::Run'); + expect(callers).toContain('CDetector::Flush'); + + const runMethod = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('CDetector::Run')); + expect(runMethod).toBeDefined(); + const callees = cg.getCallees(runMethod!.id).map((c) => c.node.qualifiedName); + expect(callees).toContain('CDetect::Processing'); + } finally { + cg?.close(); + } + }); + + it('resolves typed pointer callers when the method name is ambiguous and the call sits inside a return/declaration', async () => { + // Regression: an earlier version of the C++ receiver-type inference matched + // the call line itself (`return m_cpAlg->Processing()`) and treated `return` + // as the type, OR grabbed `int r =` as a type from the prefix. With Strategy + // 3's "unique method name" fallback, the original issue example resolved + // anyway — but as soon as two classes share a method name (very common in + // real C++), both calls go unresolved. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'detect.hpp'), + 'class CDetect { public: int Processing(); };\n' + + 'class CWidget { public: int Processing(); };\n' + + 'class CDetector {\n' + + ' private:\n' + + ' CDetect* m_cpAlg = nullptr;\n' + + ' public:\n' + + ' int RunReturn();\n' + + ' int RunAssign();\n' + + '};\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'detect.cpp'), + '#include "detect.hpp"\n' + + 'int CDetector::RunReturn() { return m_cpAlg->Processing(); }\n' + + 'int CDetector::RunAssign() { int r = m_cpAlg->Processing(); return r; }\n' + + 'int CDetect::Processing() { return 0; }\n' + + 'int CWidget::Processing() { return 0; }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const detectProc = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'CDetect::Processing'); + const widgetProc = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'CWidget::Processing'); + expect(detectProc).toBeDefined(); + expect(widgetProc).toBeDefined(); + + const detectCallers = cg.getCallers(detectProc!.id).map((c) => c.node.qualifiedName); + expect(detectCallers).toContain('CDetector::RunReturn'); + expect(detectCallers).toContain('CDetector::RunAssign'); + + // CWidget::Processing is never called — calls must NOT misroute here. + const widgetCallers = cg.getCallers(widgetProc!.id).map((c) => c.node.qualifiedName); + expect(widgetCallers).not.toContain('CDetector::RunReturn'); + expect(widgetCallers).not.toContain('CDetector::RunAssign'); + } finally { + cg?.close(); + } + }); + + it('bridges a base virtual method to the subclass override', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + fs.writeFileSync( + path.join(tmpDir, 'iter.cpp'), + 'class Iterator {\n' + + ' public:\n' + + ' virtual void Next() { }\n' + + '};\n' + + 'class DBIter : public Iterator {\n' + + ' public:\n' + + ' void Next() override { advance(); }\n' + + ' void advance() { }\n' + + '};\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Two methods named Next: the base virtual (lower line) and the override. + const nexts = cg + .getNodesByKind('method') + .filter((n) => n.name === 'Next') + .sort((a, b) => a.startLine - b.startLine); + expect(nexts.length).toBe(2); + const [baseNext, overrideNext] = nexts; + + // A vtable call to Iterator::Next dispatches to DBIter::Next — bridge it so + // trace/callees from the interface method reaches the implementation. + const edge = cg + .getOutgoingEdges(baseNext!.id) + .find((e) => e.target === overrideNext!.id && e.kind === 'calls'); + expect(edge, 'Iterator::Next should reach DBIter::Next via override synthesis').toBeDefined(); + + cg.close(); + }); +}); + +describe('Java end-to-end — field-injected bean trace (issue #389)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + // Mirrors the issue's Spring MVC pattern: + // UserAction(@Resource UserBO userbo).toLogin2() -> this.userbo.toLogin2() + // -> UserBO.toLogin2() -> userService.toLogin() -> UserService.toLogin (iface) + // -> UserServiceImpl.toLogin() via interface→impl synthesis. + // Without the extractor `this.` strip + field-typed receiver lookup, the very + // first hop (controller -> bean) was missing entirely, breaking trace. + it('connects controller -> @Resource bean -> interface -> impl end-to-end', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-bean-')); + const javaDir = path.join(tmpDir, 'src/main/java/com/example/user'); + fs.mkdirSync(path.join(javaDir, 'action'), { recursive: true }); + fs.mkdirSync(path.join(javaDir, 'bo'), { recursive: true }); + fs.mkdirSync(path.join(javaDir, 'service'), { recursive: true }); + fs.mkdirSync(path.join(javaDir, 'service/impl'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'org.springframework.bootspring-boot-starter-web\n' + ); + fs.writeFileSync( + path.join(javaDir, 'action/UserAction.java'), + 'package com.example.user.action;\n' + + 'import com.example.user.bo.UserBO;\n' + + 'import javax.annotation.Resource;\n' + + '@org.springframework.stereotype.Controller\n' + + 'public class UserAction {\n' + + ' @Resource(name = "userBO") private UserBO userbo;\n' + + ' public void toLogin2() { this.userbo.toLogin2(); }\n' + + '}\n' + ); + fs.writeFileSync( + path.join(javaDir, 'bo/UserBO.java'), + 'package com.example.user.bo;\n' + + 'import com.example.user.service.UserService;\n' + + 'import javax.annotation.Resource;\n' + + '@org.springframework.stereotype.Component("userBO")\n' + + 'public class UserBO {\n' + + ' @Resource private UserService userService;\n' + + ' public void toLogin2() { userService.toLogin(); }\n' + + '}\n' + ); + fs.writeFileSync( + path.join(javaDir, 'service/UserService.java'), + 'package com.example.user.service;\n' + + 'public interface UserService { void toLogin(); }\n' + ); + fs.writeFileSync( + path.join(javaDir, 'service/impl/UserServiceImpl.java'), + 'package com.example.user.service.impl;\n' + + 'import com.example.user.service.UserService;\n' + + '@org.springframework.stereotype.Service("userService")\n' + + 'public class UserServiceImpl implements UserService {\n' + + ' public void toLogin() { }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const find = (cls: string, name: string) => + methods.find((m) => m.name === name && m.filePath.endsWith(`${cls}.java`)); + + const action = find('UserAction', 'toLogin2'); + const bo = find('UserBO', 'toLogin2'); + const svc = find('UserService', 'toLogin'); + const impl = find('UserServiceImpl', 'toLogin'); + expect(action).toBeDefined(); + expect(bo).toBeDefined(); + expect(svc).toBeDefined(); + expect(impl).toBeDefined(); + + // UserAction.toLogin2 -> UserBO.toLogin2 (the regressed hop — `this.userbo` + // receiver was emitted verbatim and the field-type lookup didn't exist). + const actionToBo = cg.getOutgoingEdges(action!.id).find((e) => e.target === bo!.id); + expect(actionToBo, 'controller `this.userbo.toLogin2()` should reach UserBO.toLogin2').toBeDefined(); + expect(actionToBo!.kind).toBe('calls'); + + // UserBO.toLogin2 -> UserService.toLogin (plain identifier receiver, works pre-fix). + const boToSvc = cg.getOutgoingEdges(bo!.id).find((e) => e.target === svc!.id); + expect(boToSvc).toBeDefined(); + + // UserService.toLogin -> UserServiceImpl.toLogin (interface->impl synth). + const svcToImpl = cg.getOutgoingEdges(svc!.id).find((e) => e.target === impl!.id); + expect(svcToImpl).toBeDefined(); + + cg.close(); + }); + + it('bridges a Java mapper interface method to its MyBatis XML statement (incl. SQL fragments)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-mybatis-')); + const javaDir = path.join(tmpDir, 'src/main/java/com/example/dao'); + const xmlDir = path.join(tmpDir, 'src/main/resources/mappers'); + fs.mkdirSync(javaDir, { recursive: true }); + fs.mkdirSync(xmlDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'org.mybatismybatis\n' + ); + fs.writeFileSync( + path.join(javaDir, 'UserDAOMapper.java'), + 'package com.example.dao;\n' + + 'public interface UserDAOMapper {\n' + + ' Object getById(int id);\n' + + ' int updateUser(Object u);\n' + + '}\n' + ); + fs.writeFileSync( + path.join(xmlDir, 'UserDAOMapper.xml'), + '\n' + + '\n' + + '\n' + + ' id, name, email\n' + + ' \n' + + ' \n' + + ' UPDATE users SET name=#{name}, email=#{email} WHERE id=#{id}\n' + + ' \n' + + '\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const getByIdJava = methods.find((m) => m.name === 'getById' && m.language === 'java'); + const getByIdXml = methods.find((m) => m.name === 'getById' && m.language === 'xml'); + const updateJava = methods.find((m) => m.name === 'updateUser' && m.language === 'java'); + const updateXml = methods.find((m) => m.name === 'updateUser' && m.language === 'xml'); + const sqlFrag = methods.find((m) => m.name === 'userCols' && m.language === 'xml'); + expect(getByIdJava).toBeDefined(); + expect(getByIdXml).toBeDefined(); + expect(updateJava).toBeDefined(); + expect(updateXml).toBeDefined(); + expect(sqlFrag).toBeDefined(); + + // XML statement qualified name must be `::` so the + // synthesizer can match against the Java method's `::` + // suffix — this is the load-bearing contract between extractor + synthesis. + expect(getByIdXml!.qualifiedName).toBe('com.example.dao.UserDAOMapper::getById'); + + // Bridge: Java mapper method -> XML statement, kind 'calls'. + const j2xGet = cg.getOutgoingEdges(getByIdJava!.id).find((e) => e.target === getByIdXml!.id); + expect(j2xGet, 'Java getById should reach the XML -> in same mapper. + const incEdge = cg.getOutgoingEdges(getByIdXml!.id).find((e) => e.target === sqlFrag!.id); + expect(incEdge, ' should reach the fragment').toBeDefined(); + + cg.close(); + }); + + it('binds @Value / @ConfigurationProperties to YAML + .properties keys (incl. relaxed binding)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-config-')); + const javaDir = path.join(tmpDir, 'src/main/java/com/example'); + const resDir = path.join(tmpDir, 'src/main/resources'); + fs.mkdirSync(javaDir, { recursive: true }); + fs.mkdirSync(resDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'org.springframework.bootspring-boot-starter\n' + ); + fs.writeFileSync( + path.join(resDir, 'application.yml'), + 'app:\n' + + ' cache:\n' + + ' name:\n' + + ' user-token: "example-service:auth:token"\n' + + ' enabled: true\n' + + 'db:\n' + + ' url: "jdbc:mysql://localhost/x"\n' + ); + fs.writeFileSync( + path.join(resDir, 'application.properties'), + 'app.retry-count=3\n' + ); + fs.writeFileSync( + path.join(javaDir, 'CacheConfig.java'), + 'package com.example;\n' + + 'import org.springframework.beans.factory.annotation.Value;\n' + + 'public class CacheConfig {\n' + + ' @Value("${app.cache.name.user-token}") private String tokenCacheName;\n' + + ' @Value("${app.cache.enabled:true}") private boolean enabled;\n' + + ' // relaxed binding: java camelCase, properties kebab-case\n' + + ' @Value("${app.retryCount}") private int retry;\n' + + '}\n' + ); + fs.writeFileSync( + path.join(javaDir, 'CacheProperties.java'), + 'package com.example;\n' + + 'import org.springframework.boot.context.properties.ConfigurationProperties;\n' + + '@ConfigurationProperties(prefix = "app.cache")\n' + + 'public class CacheProperties { private boolean enabled; }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // YAML/properties leaf keys: one constant node per dotted path. + const cfgKeys = cg + .getNodesByKind('constant') + .filter((n) => n.language === 'yaml' || n.language === 'properties'); + const cfgByQn = (qn: string) => cfgKeys.find((n) => n.qualifiedName === qn); + expect(cfgByQn('app.cache.name.user-token')).toBeDefined(); + expect(cfgByQn('app.cache.enabled')).toBeDefined(); + expect(cfgByQn('db.url')).toBeDefined(); + expect(cfgByQn('app.retry-count')).toBeDefined(); + + // @Value("${app.cache.name.user-token}") -> the YAML leaf key. + const valueBindings = cg + .getNodesByKind('constant') + .filter((n) => n.id.startsWith('spring-value:')); + const userToken = valueBindings.find((n) => n.name === 'app.cache.name.user-token'); + expect(userToken).toBeDefined(); + const userTokenEdges = cg.getOutgoingEdges(userToken!.id); + const userTokenTarget = userTokenEdges.find((e) => + cfgKeys.some((c) => c.id === e.target && c.qualifiedName === 'app.cache.name.user-token'), + ); + expect(userTokenTarget, '@Value should reference the YAML leaf key').toBeDefined(); + + // Default-value form `${k:default}` — strip the `:default` and bind the key. + const enabledBind = valueBindings.find((n) => n.name === 'app.cache.enabled'); + expect(enabledBind).toBeDefined(); + expect(cg.getOutgoingEdges(enabledBind!.id).some((e) => { + const t = cfgByQn('app.cache.enabled'); + return t && e.target === t.id; + })).toBe(true); + + // Relaxed binding: `app.retryCount` (camel) -> `app.retry-count` (kebab). + const retryBind = valueBindings.find((n) => n.name === 'app.retryCount'); + expect(retryBind).toBeDefined(); + expect(cg.getOutgoingEdges(retryBind!.id).some((e) => { + const t = cfgByQn('app.retry-count'); + return t && e.target === t.id; + })).toBe(true); + + // @ConfigurationProperties(prefix="app.cache") -> a key under that prefix. + const cpBindings = cg + .getNodesByKind('constant') + .filter((n) => n.id.startsWith('spring-cp:')); + const cpAppCache = cpBindings.find((n) => n.name === 'app.cache'); + expect(cpAppCache).toBeDefined(); + const cpEdges = cg.getOutgoingEdges(cpAppCache!.id); + expect(cpEdges.length).toBeGreaterThan(0); + + cg.close(); + }); + + it('emits only a file node for non-MyBatis XML (pom.xml, beans.xml, log4j.xml)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-xml-non-mybatis-')); + fs.writeFileSync( + path.join(tmpDir, 'pom.xml'), + 'xy\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'log4j.xml'), + '\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + // No method nodes — non-mapper XML produces no symbols (just file rows). + expect(cg.getNodesByKind('method').filter((n) => n.language === 'xml').length).toBe(0); + cg.close(); + }); + + it('resolves a `this.field.method()` call to a unique implementation class', async () => { + // Standalone test of the extractor `this.` strip: even without Spring annotations, + // `this.svc.run()` where `svc` is typed as a concrete class should route to that + // class's method. This is the general Java fix, Spring is only one consumer. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-this-field-')); + fs.writeFileSync( + path.join(tmpDir, 'App.java'), + 'class Svc { public void run() { } }\n' + + 'class App {\n' + + ' private Svc svc;\n' + + ' public void go() { this.svc.run(); }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const go = methods.find((m) => m.name === 'go'); + const run = methods.find((m) => m.name === 'run'); + expect(go && run).toBeTruthy(); + + const edge = cg.getOutgoingEdges(go!.id).find((e) => e.target === run!.id); + expect(edge, '`this.svc.run()` should resolve to Svc.run').toBeDefined(); + + cg.close(); + }); +}); + +describe('JVM FQN imports — end-to-end', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves a Kotlin import when the file name differs from the class name', async () => { + // Bar lives in Models.kt — the filesystem-based Java-style path lookup + // (com/example/Bar.kt) misses this; only FQN-via-qualifiedName finds it. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'Models.kt'), + 'package com.example\n\nclass Bar {\n fun greet(): String = "hi"\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'Caller.kt'), + 'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar'); + expect(bar, 'Bar should be extracted with package-qualified name').toBeDefined(); + + const importNode = cg.getNodesByKind('import').find((n) => n.name === 'com.example.Bar'); + expect(importNode, 'import statement node should exist').toBeDefined(); + + // The imports edge may originate from the import node OR from a parent + // scope (file / namespace) — accept either, but require that an + // imports-kind edge to Bar exists. + const reachesBar = cg + .getIncomingEdges(bar!.id) + .find((e) => e.kind === 'imports'); + expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined(); + + cg.close(); + }); + + it('resolves a Kotlin top-level function import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'Utils.kt'), + 'package com.example\n\nfun util(): Int = 42\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'Caller.kt'), + 'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util'); + expect(util, 'top-level util() should be extracted under com.example').toBeDefined(); + + const edge = cg.getIncomingEdges(util!.id).find((e) => e.kind === 'imports'); + expect(edge, 'imports edge should reach the top-level function by FQN').toBeDefined(); + }); + + it('resolves cross-language: Kotlin importing a Java class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'JavaBar.java'), + 'package com.example;\n\npublic class JavaBar {\n public String greet() { return "hi"; }\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'Caller.kt'), + 'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar'); + expect(javaBar, 'JavaBar should be extracted under com.example regardless of language').toBeDefined(); + + const edge = cg.getIncomingEdges(javaBar!.id).find((e) => e.kind === 'imports'); + expect(edge, 'Kotlin caller should resolve its import to the Java class').toBeDefined(); + }); + + it('disambiguates a class-name collision across packages', async () => { + // Two `Bar` classes in different packages — each importer should reach + // ITS Bar, not the other one. This is the central failure mode that + // name-matcher alone cannot disambiguate. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-')); + fs.writeFileSync( + path.join(tmpDir, 'AlphaBar.kt'), + 'package com.example.alpha\n\nclass Bar { fun who() = "alpha" }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'BetaBar.kt'), + 'package com.example.beta\n\nclass Bar { fun who() = "beta" }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'CallerA.kt'), + 'package app\n\nimport com.example.alpha.Bar\n\nfun a() { Bar().who() }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'CallerB.kt'), + 'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar'); + const betaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.beta::Bar'); + expect(alphaBar).toBeDefined(); + expect(betaBar).toBeDefined(); + expect(alphaBar!.id).not.toBe(betaBar!.id); + + // Each Bar receives exactly one imports edge — from its own caller. + const alphaIncoming = cg.getIncomingEdges(alphaBar!.id).filter((e) => e.kind === 'imports'); + const betaIncoming = cg.getIncomingEdges(betaBar!.id).filter((e) => e.kind === 'imports'); + expect(alphaIncoming.length).toBeGreaterThan(0); + expect(betaIncoming.length).toBeGreaterThan(0); + + // Sanity: the edges don't cross — alpha's incoming sources don't include + // beta's filePath and vice versa. + const sourceFiles = (edges: typeof alphaIncoming) => + edges.map((e) => cg.getNode(e.source)?.filePath).filter(Boolean); + expect(sourceFiles(alphaIncoming).some((p) => p?.includes('CallerA.kt'))).toBe(true); + expect(sourceFiles(betaIncoming).some((p) => p?.includes('CallerB.kt'))).toBe(true); + }); +}); + +describe('Java anonymous-class override synthesis — end-to-end', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('bridges an abstract base method to overrides inside `new Base() { ... }`', async () => { + // Mirrors guava Splitter: a factory returns `new BaseIter() { + // @Override int separatorStart(...) { ... } }`. Without anon-class + // extraction the override is invisible — Phase 5.5 interface-impl + // has no class to bridge — and an agent investigating `BaseIter.separatorStart` + // can't see its real implementation without reading the file. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-anon-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Splitter.java'), + 'package com.example;\n' + + '\n' + + 'abstract class BaseIter {\n' + + ' abstract int separatorStart(int start);\n' + + '}\n' + + '\n' + + 'public class Splitter {\n' + + ' public BaseIter make() {\n' + + ' return new BaseIter() {\n' + + ' @Override\n' + + ' int separatorStart(int start) { return start + 1; }\n' + + ' };\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // The anon class is extracted and contains the override. + const anonClass = cg + .getNodesByKind('class') + .find((n) => /BaseIter\$anon@/.test(n.name)); + expect(anonClass, 'anonymous BaseIter subclass should be a class node').toBeDefined(); + + const baseAbstract = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'com.example::BaseIter::separatorStart'); + const anonOverride = cg + .getNodesByKind('method') + .find( + (n) => + n.name === 'separatorStart' && + n.qualifiedName.includes('$anon@') && + n.qualifiedName.startsWith('com.example::Splitter::make::') + ); + expect(baseAbstract, 'base abstract method should be in the graph').toBeDefined(); + expect(anonOverride, 'anon-class override should be in the graph').toBeDefined(); + + // Phase 5.5 interface-impl: the abstract method has a synthesized + // `calls` edge to the anon override. Without this hop the agent + // would have to Read the file to discover the implementation. + const synthEdge = cg + .getOutgoingEdges(baseAbstract!.id) + .find((e) => e.target === anonOverride!.id && e.kind === 'calls'); + expect(synthEdge, 'BaseIter.separatorStart should bridge to anon.separatorStart').toBeDefined(); + expect(synthEdge!.provenance).toBe('heuristic'); + expect((synthEdge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy).toBe( + 'interface-impl' + ); + + cg.close(); + }); +}); + +describe('Go gRPC stub→impl synthesis', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('bridges UnimplementedMsgServer methods to the hand-written keeper impl', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-grpc-')); + // Mimic protoc-gen-go-grpc output: `*_grpc.pb.go` carrying the + // UnimplementedMsgServer stub. + fs.writeFileSync( + path.join(tmpDir, 'tx_grpc.pb.go'), + 'package banktypes\n\n' + + 'type UnimplementedMsgServer struct{}\n\n' + + 'func (UnimplementedMsgServer) Send(ctx context.Context, req *MsgSend) (*MsgSendResponse, error) { return nil, nil }\n' + + 'func (UnimplementedMsgServer) MultiSend(ctx context.Context, req *MsgMultiSend) (*MsgMultiSendResponse, error) { return nil, nil }\n' + + 'func (UnimplementedMsgServer) mustEmbedUnimplementedMsgServer() {}\n' + + 'func (UnimplementedMsgServer) testEmbeddedByValue() {}\n' + ); + // Hand-written impl in a non-generated file — what an agent actually + // wants the trace to land on. + fs.writeFileSync( + path.join(tmpDir, 'msg_server.go'), + 'package keeper\n\n' + + 'type msgServer struct{ k Keeper }\n\n' + + 'func (m msgServer) Send(ctx context.Context, req *MsgSend) (*MsgSendResponse, error) {\n' + + ' return m.k.SendCoins(ctx, req.From, req.To, req.Amount)\n' + + '}\n' + + 'func (m msgServer) MultiSend(ctx context.Context, req *MsgMultiSend) (*MsgMultiSendResponse, error) {\n' + + ' return nil, nil\n' + + '}\n' + ); + + let cg: CodeGraph | undefined; + try { + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const stubSend = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('UnimplementedMsgServer::Send')); + const implSend = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('msgServer::Send')); + expect(stubSend, 'UnimplementedMsgServer.Send should be indexed').toBeDefined(); + expect(implSend, 'msgServer.Send should be indexed').toBeDefined(); + + const bridge = cg + .getOutgoingEdges(stubSend!.id) + .find((e) => e.target === implSend!.id && e.kind === 'calls'); + expect(bridge, 'stub Send should bridge to impl Send').toBeDefined(); + expect(bridge!.provenance).toBe('heuristic'); + expect((bridge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy).toBe( + 'go-grpc-stub-impl' + ); + } finally { + cg?.close(); + } + }); + + it('does not bridge to candidates living in another generated file', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-grpc-sib-')); + // `*_grpc.pb.go` also contains a sibling `msgClient` struct that + // happens to satisfy the same method set. We must NOT bridge to it — + // it's not the hand-written impl, just the gRPC client wrapper. + fs.writeFileSync( + path.join(tmpDir, 'tx_grpc.pb.go'), + 'package banktypes\n\n' + + 'type UnimplementedMsgServer struct{}\n' + + 'func (UnimplementedMsgServer) Send() {}\n' + + 'func (UnimplementedMsgServer) MultiSend() {}\n\n' + + 'type msgClient struct{}\n' + + 'func (m msgClient) Send() {}\n' + + 'func (m msgClient) MultiSend() {}\n' + ); + + let cg: CodeGraph | undefined; + try { + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const stub = cg + .getNodesByKind('struct') + .find((n) => n.name === 'UnimplementedMsgServer'); + expect(stub).toBeDefined(); + const bridges = cg + .getNodesByKind('method') + .filter((n) => n.qualifiedName.endsWith('UnimplementedMsgServer::Send')) + .flatMap((stubSend) => cg!.getOutgoingEdges(stubSend.id)) + .filter( + (e) => + e.kind === 'calls' && + (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === + 'go-grpc-stub-impl', + ); + expect(bridges, 'no bridge to msgClient (also generated)').toHaveLength(0); + } finally { + cg?.close(); + } + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index a5e5c56b3..ff1abb57b 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -123,6 +123,52 @@ def create_user(id): expect(nodes[0].name).toBe('POST /'); expect(references[0].referenceName).toBe('create_user'); }); + + it('resolves the handler across an intervening decorator (@login_required)', () => { + const src = ` +@bp.route('/profile') +@login_required +def profile(): + return render_template('profile.html') +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes[0].name).toBe('GET /profile'); + expect(references[0].referenceName).toBe('profile'); + }); + + it('extracts stacked @x.route decorators bound to one view', () => { + const src = ` +@bp.route('/', methods=['GET', 'POST']) +@bp.route('/index', methods=['GET', 'POST']) +@login_required +def index(): + return render_template('index.html') +`; + const { nodes, references } = flaskResolver.extract!('routes.py', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /', 'GET /index']); + expect(references.map((r) => r.referenceName)).toEqual(['index', 'index']); + }); + + it('extracts the method from a tuple methods=(...) (not just a list)', () => { + const src = ` +@blueprint.route('/api/articles', methods=('POST',)) +def make_article(): + pass +`; + const { nodes, references } = flaskResolver.extract!('views.py', src); + expect(nodes[0].name).toBe('POST /api/articles'); + expect(references[0].referenceName).toBe('make_article'); + }); + + it('extracts Flask-RESTful api.add_resource(Resource, paths) → the Resource class', () => { + const src = ` +api.add_resource(TodoResource, '/todos/') +api.add_org_resource(AlertResource, '/api/alerts/', endpoint='alert') +`; + const { nodes, references } = flaskResolver.extract!('api.py', src); + expect(nodes.map((n) => n.name)).toEqual(['ANY /todos/', 'ANY /api/alerts/']); + expect(references.map((r) => r.referenceName)).toEqual(['TodoResource', 'AlertResource']); + }); }); describe('fastapiResolver.extract', () => { @@ -147,6 +193,32 @@ def create_item(item: Item): expect(nodes[0].name).toBe('POST /items'); expect(references[0].referenceName).toBe('create_item'); }); + + it('extracts a route mounted at the router/prefix root (empty path)', () => { + const src = ` +@router.get("", response_model=ListOfArticles, name="articles:list") +async def list_articles(): + return [] +`; + const { nodes, references } = fastapiResolver.extract!('articles.py', src); + expect(nodes[0].name).toBe('GET /'); + expect(references[0].referenceName).toBe('list_articles'); + }); + + it('extracts a multi-line decorator with an empty path', () => { + const src = ` +@router.post( + "", + status_code=201, + response_model=ArticleInResponse, +) +async def create_article(): + pass +`; + const { nodes, references } = fastapiResolver.extract!('articles.py', src); + expect(nodes[0].name).toBe('POST /'); + expect(references[0].referenceName).toBe('create_article'); + }); }); import { expressResolver } from '../src/resolution/frameworks/express'; @@ -456,6 +528,271 @@ describe('nestjsResolver.resolve', () => { }); }); +describe('nestjsResolver.postExtract — RouterModule', () => { + function mkClass(name: string, filePath: string, startLine: number, endLine: number): Node { + return { + id: `class:${filePath}:${startLine}:${name}`, + kind: 'class', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language: 'typescript', + startLine, + endLine, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + } + + function mkRoute( + filePath: string, + line: number, + method: string, + path: string, + nameOverride?: string + ): Node { + return { + id: `route:${filePath}:${line}:${method}:${path}`, + kind: 'route', + name: nameOverride ?? `${method} ${path}`, + qualifiedName: `${filePath}::${method}:${path}`, + filePath, + language: 'typescript', + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; + } + + function makeContext(opts: { + files?: Record; + nodes?: Node[]; + }) { + const files = opts.files ?? {}; + const all = opts.nodes ?? []; + return { + getNodesInFile: (fp: string) => all.filter((n) => n.filePath === fp), + getNodesByName: (name: string) => all.filter((n) => n.name === name), + getNodesByQualifiedName: () => [], + getNodesByKind: (kind: Node['kind']) => all.filter((n) => n.kind === kind), + fileExists: (fp: string) => files[fp] !== undefined, + readFile: (fp: string) => files[fp] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => Object.keys(files), + getNodesByLowerName: () => [], + getImportMappings: () => [], + } as any; + } + + it('prepends RouterModule prefix to a controller route (top-level register)', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + @Module({ + imports: [ + RouterModule.register([ + { path: 'admin', module: AdminModule }, + ]), + ], + }) + export class AppModule {} + + @Module({ controllers: [AdminController] }) + export class AdminModule {} + `, + }, + nodes: [ + mkClass('AdminController', 'src/admin/admin.controller.ts', 1, 10), + mkRoute('src/admin/admin.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(1); + expect(updates[0]!.name).toBe('GET /admin'); + // id and qualifiedName must be preserved so existing route→handler edges + // stay intact and the pass remains idempotent on a second run. + expect(updates[0]!.id).toBe('route:src/admin/admin.controller.ts:3:GET:/'); + expect(updates[0]!.qualifiedName).toBe('src/admin/admin.controller.ts::GET:/'); + }); + + it('resolves nested children — the issue #459 example', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + @Module({ + imports: [ + AdminModule, + UsersModule, + RouterModule.register([ + { + path: 'admin', + module: AdminModule, + children: [ + { path: 'users', module: UsersModule }, + ], + }, + ]), + ], + }) + export class AppModule {} + `, + 'src/users/users.module.ts': ` + @Module({ controllers: [UsersController] }) + export class UsersModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users/users.controller.ts', 1, 10), + mkRoute('src/users/users.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(1); + expect(updates[0]!.name).toBe('GET /admin/users'); + }); + + it('joins module prefix with a non-empty @Controller path and method params', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'admin', module: UsersModule }]) + + @Module({ controllers: [UsersController] }) + export class UsersModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users.controller.ts', 1, 10), + // Existing extract emitted GET /users/:id from @Controller('users') + @Get(':id') + mkRoute('src/users.controller.ts', 3, 'GET', '/users/:id'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(1); + expect(updates[0]!.name).toBe('GET /admin/users/:id'); + }); + + it('is idempotent — a second run returns no updates', () => { + // Simulate the state after one round of postExtract: name is already + // 'GET /admin', but qualifiedName still encodes the original 'GET:/'. + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'admin', module: UsersModule }]) + @Module({ controllers: [UsersController] }) + export class UsersModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users.controller.ts', 1, 10), + mkRoute('src/users.controller.ts', 3, 'GET', '/', 'GET /admin'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(0); + }); + + it('is a no-op when the project does not use RouterModule', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + @Module({ controllers: [UsersController] }) + export class AppModule {} + `, + }, + nodes: [ + mkClass('UsersController', 'src/users.controller.ts', 1, 10), + mkRoute('src/users.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(0); + }); + + it('attributes routes to the right controller when one file has two', () => { + // Two controllers in one file, declared in two different modules with + // two different module prefixes. The route's startLine has to match the + // class scope, not just the file path. + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([ + { path: 'p1', module: AModule }, + { path: 'p2', module: BModule }, + ]) + @Module({ controllers: [AController] }) export class AModule {} + @Module({ controllers: [BController] }) export class BModule {} + `, + }, + nodes: [ + mkClass('AController', 'src/multi.controller.ts', 1, 5), + mkClass('BController', 'src/multi.controller.ts', 7, 12), + mkRoute('src/multi.controller.ts', 3, 'GET', '/a/x'), + mkRoute('src/multi.controller.ts', 9, 'GET', '/b/y'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(2); + const byId = new Map(updates.map((u) => [u.id, u.name])); + expect(byId.get('route:src/multi.controller.ts:3:GET:/a/x')).toBe('GET /p1/a/x'); + expect(byId.get('route:src/multi.controller.ts:9:GET:/b/y')).toBe('GET /p2/b/y'); + }); + + it('merges RouterModule registrations spread across multiple module files', () => { + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'a', module: AModule }]) + @Module({ controllers: [AController] }) export class AModule {} + `, + 'src/feature.module.ts': ` + RouterModule.forChild([{ path: 'b', module: BModule }]) + @Module({ controllers: [BController] }) export class BModule {} + `, + }, + nodes: [ + mkClass('AController', 'src/a.controller.ts', 1, 5), + mkClass('BController', 'src/b.controller.ts', 1, 5), + mkRoute('src/a.controller.ts', 3, 'GET', '/'), + mkRoute('src/b.controller.ts', 3, 'GET', '/'), + ], + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(2); + const byId = new Map(updates.map((u) => [u.id, u.name])); + expect(byId.get('route:src/a.controller.ts:3:GET:/')).toBe('GET /a'); + expect(byId.get('route:src/b.controller.ts:3:GET:/')).toBe('GET /b'); + }); + + it('silently skips controllers whose class node is not in the graph', () => { + // RouterModule declares a prefix for a module, but the @Module that + // would link it to a controller is missing — common during partial + // re-extraction. Must not throw. + const ctx = makeContext({ + files: { + 'src/app.module.ts': ` + RouterModule.register([{ path: 'orphans', module: GhostModule }]) + @Module({ controllers: [GhostController] }) export class GhostModule {} + `, + }, + nodes: [], // no class or route nodes + }); + + const updates = nestjsResolver.postExtract!(ctx); + expect(updates).toHaveLength(0); + }); +}); + import { laravelResolver } from '../src/resolution/frameworks/laravel'; describe('laravelResolver.extract', () => { @@ -463,13 +800,13 @@ describe('laravelResolver.extract', () => { const src = `Route::get('/users', [UserController::class, 'index']);\n`; const { nodes, references } = laravelResolver.extract!('routes/web.php', src); expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('index'); + expect(references[0].referenceName).toBe('UserController@index'); }); it('extracts route with Controller@action syntax', () => { const src = `Route::post('/users', 'UserController@store');\n`; const { nodes, references } = laravelResolver.extract!('routes/web.php', src); - expect(references[0].referenceName).toBe('store'); + expect(references[0].referenceName).toBe('UserController@store'); }); it('extracts resource route', () => { @@ -487,13 +824,13 @@ describe('railsResolver.extract', () => { const src = `get '/users', to: 'users#index'\n`; const { nodes, references } = railsResolver.extract!('config/routes.rb', src); expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('index'); + expect(references[0].referenceName).toBe('users#index'); }); it('extracts route without to: keyword', () => { const src = `post '/items' => 'items#create'\n`; const { nodes, references } = railsResolver.extract!('config/routes.rb', src); - expect(references[0].referenceName).toBe('create'); + expect(references[0].referenceName).toBe('items#create'); }); }); @@ -511,6 +848,75 @@ public List listUsers() { expect(nodes[0].name).toBe('GET /users'); expect(references[0].referenceName).toBe('listUsers'); }); + + it('extracts a Kotlin @GetMapping with a fun handler', () => { + const src = ` +@GetMapping("/vets") +fun showVetList(model: MutableMap): String { + return "vets" +} +`; + const { nodes, references } = springResolver.extract!('VetController.kt', src); + expect(nodes[0].name).toBe('GET /vets'); + expect(references[0].referenceName).toBe('showVetList'); + expect(nodes[0].language).toBe('kotlin'); + }); + + it('joins a Kotlin class @RequestMapping prefix and skips a stacked annotation', () => { + const src = ` +@RestController +@RequestMapping("/owners") +class OwnerController { + @GetMapping("/{ownerId}") + @ResponseBody + fun showOwner(@PathVariable ownerId: Int): String { + return "owner" + } +} +`; + const { nodes, references } = springResolver.extract!('OwnerController.kt', src); + expect(nodes[0].name).toBe('GET /owners/{ownerId}'); + expect(references[0].referenceName).toBe('showOwner'); + }); +}); + +import { playResolver } from '../src/resolution/frameworks/play'; +import { isSourceFile, isPlayRoutesFile } from '../src/extraction/grammars'; + +describe('playResolver.extract (conf/routes)', () => { + it('extracts METHOD /path Controller.action routes, dropping the package + args', () => { + const src = `# Routes +GET / controllers.Application.index +GET /computers controllers.Application.list(p: Int ?= 0, s: Int ?= 2) +POST /computers controllers.Application.save +-> /v1/posts v1.post.PostRouter +`; + const { nodes, references } = playResolver.extract!('conf/routes', src); + expect(nodes.map((n) => n.name)).toEqual([ + 'GET /', + 'GET /computers', + 'POST /computers', + ]); // the `->` include is skipped + expect(references.map((r) => r.referenceName)).toEqual([ + 'Application.index', + 'Application.list', + 'Application.save', + ]); + }); + + it('only runs on Play routes files', () => { + expect(playResolver.extract!('app/Foo.scala', 'GET / controllers.X.y').nodes).toHaveLength(0); + }); +}); + +describe('Play routes file detection', () => { + it('recognizes conf/routes (extensionless) and *.routes as source files', () => { + expect(isPlayRoutesFile('conf/routes')).toBe(true); + expect(isPlayRoutesFile('myapp/conf/routes')).toBe(true); + expect(isPlayRoutesFile('conf/admin.routes')).toBe(true); + expect(isSourceFile('conf/routes')).toBe(true); + expect(isPlayRoutesFile('src/routes.ts')).toBe(false); + }); }); import { goResolver } from '../src/resolution/frameworks/go'; @@ -528,6 +934,14 @@ describe('goResolver.extract', () => { const { nodes, references } = goResolver.extract!('main.go', src); expect(references[0].referenceName).toBe('createItem'); }); + + it('extracts gorilla/mux HandleFunc on a subrouter var, ignoring chained .Methods()', () => { + // `s` is a PathPrefix().Subrouter() var — any receiver is matched; the + // trailing .Methods("GET") doesn't break the handler capture. + const src = `s.HandleFunc("/users/{id}", listUsers).Methods("GET")\n`; + const { references } = goResolver.extract!('routes.go', src); + expect(references[0].referenceName).toBe('listUsers'); + }); }); import { rustResolver } from '../src/resolution/frameworks/rust'; @@ -539,6 +953,50 @@ describe('rustResolver.extract', () => { expect(nodes[0].name).toBe('GET /users'); expect(references[0].referenceName).toBe('list_users'); }); + + it('extracts every method from a chained axum .route (get().put())', () => { + const src = `let app = Router::new().route("/user", get(get_current_user).put(update_user));\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /user', 'PUT /user']); + expect(references.map((r) => r.referenceName)).toEqual([ + 'get_current_user', + 'update_user', + ]); + }); + + it('extracts a multi-line axum .route with a namespaced handler', () => { + const src = ` +let app = Router::new() + .route( + "/articles/feed", + get(listing::feed_articles), + ); +`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /articles/feed'); + expect(references[0].referenceName).toBe('feed_articles'); + }); + + it('extracts actix web::resource().route(web::METHOD().to(handler))', () => { + const src = `App::new().service(web::resource("/user/{id}").route(web::get().to(get_user)))\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /user/{id}'); + expect(references[0].referenceName).toBe('get_user'); + }); + + it('extracts actix web::resource("/").to(handler) (all methods)', () => { + const src = `App::new().service(web::resource("/").to(index))\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('ANY /'); + expect(references[0].referenceName).toBe('index'); + }); + + it('extracts actix App-level .route("/path", web::METHOD().to(handler))', () => { + const src = `App::new().route("/health", web::get().to(health_check))\n`; + const { nodes, references } = rustResolver.extract!('main.rs', src); + expect(nodes[0].name).toBe('GET /health'); + expect(references[0].referenceName).toBe('health_check'); + }); }); describe('rustResolver.resolve cargo workspace crates', () => { @@ -871,22 +1329,95 @@ describe('vaporResolver.extract', () => { it('extracts route from app.get with use:', () => { const src = `app.get("users", use: listUsers)\n`; const { nodes, references } = vaporResolver.extract!('routes.swift', src); - expect(nodes[0].name).toBe('GET users'); + expect(nodes[0].name).toBe('GET /users'); expect(references[0].referenceName).toBe('listUsers'); }); + + it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => { + const src = ` +func boot(routes: RoutesBuilder) throws { + let todos = routes.grouped("todos") + todos.get(use: index) + todos.post(use: create) + todos.group(":todoID") { todo in + todo.delete(use: delete) + } +} +`; + const { nodes, references } = vaporResolver.extract!('TodoController.swift', src); + expect(nodes.map((n) => n.name).sort()).toEqual([ + 'DELETE /todos/:todoID', + 'GET /todos', + 'POST /todos', + ]); + expect(references.map((r) => r.referenceName).sort()).toEqual([ + 'create', + 'delete', + 'index', + ]); + }); + + it('handles use: self.handler and non-string path segments', () => { + const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`; + const { nodes, references } = vaporResolver.extract!('UserController.swift', src); + expect(nodes[0].name).toBe('GET /users/edit'); + expect(references[0].referenceName).toBe('editUserHandler'); + }); + + it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => { + const src = `let host = Environment.get("DATABASE_HOST") ?? "localhost"\n`; + const { nodes } = vaporResolver.extract!('configure.swift', src); + expect(nodes).toHaveLength(0); + }); }); import { reactResolver } from '../src/resolution/frameworks/react'; import { svelteResolver } from '../src/resolution/frameworks/svelte'; +import { astroResolver } from '../src/resolution/frameworks/astro'; -describe('reactResolver.extract (smoke)', () => { - it('returns { nodes, references } shape', () => { +describe('reactResolver.extract — React Router', () => { + it('extracts a v6 }>', () => { const src = `}/>`; - const result = reactResolver.extract!('App.tsx', src); - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('references'); - expect(Array.isArray(result.nodes)).toBe(true); - expect(Array.isArray(result.references)).toBe(true); + const { nodes, references } = reactResolver.extract!('App.tsx', src); + const route = nodes.find((n) => n.kind === 'route'); + expect(route?.name).toBe('/users'); + expect(references[0]?.referenceName).toBe('UsersPage'); + }); + + it('extracts a v5 with attributes in any order', () => { + const src = ``; + const { nodes, references } = reactResolver.extract!('App.jsx', src); + const route = nodes.find((n) => n.kind === 'route'); + expect(route?.name).toBe('/login'); + expect(references[0]?.referenceName).toBe('Login'); + }); + + it('does not treat the container as a route', () => { + const src = `}/>`; + const routes = reactResolver.extract!('App.tsx', src).nodes.filter((n) => n.kind === 'route'); + expect(routes).toHaveLength(1); + expect(routes[0]?.name).toBe('/x'); + }); + + it('extracts createBrowserRouter object routes ({ path, element/Component })', () => { + const src = `const router = createBrowserRouter([ + { path: "/dashboard", element: }, + { path: "/login", Component: Login }, + ]);`; + const { nodes, references } = reactResolver.extract!('router.tsx', src); + const routes = nodes.filter((n) => n.kind === 'route'); + expect(routes.map((n) => n.name).sort()).toEqual(['/dashboard', '/login']); + expect(references.map((r) => r.referenceName).sort()).toEqual(['Dashboard', 'Login']); + }); + + it('does not treat config files or a nextjs-pages dir as Next.js routes', () => { + const cfg = reactResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}'); + expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0); + const vite = reactResolver.extract!('src/pages/vite.config.ts', 'export default {}'); + expect(vite.nodes.filter((n) => n.kind === 'route')).toHaveLength(0); + // a real page still works + const page = reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}'); + expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']); }); }); @@ -898,6 +1429,77 @@ describe('svelteResolver.extract (smoke)', () => { }); }); +describe('astroResolver.extract — src/pages file-based routing', () => { + const routeNames = (filePath: string): string[] => + astroResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name); + + it('maps index.astro to /', () => { + expect(routeNames('src/pages/index.astro')).toEqual(['/']); + }); + + it('maps nested index and plain pages', () => { + expect(routeNames('src/pages/blog/index.astro')).toEqual(['/blog']); + expect(routeNames('src/pages/about.astro')).toEqual(['/about']); + }); + + it('converts [param] and [...rest] syntax', () => { + expect(routeNames('src/pages/blog/[slug].astro')).toEqual(['/blog/:slug']); + expect(routeNames('src/pages/[...path].astro')).toEqual(['/*path']); + }); + + it('maps .ts endpoints under src/pages to routes', () => { + expect(routeNames('src/pages/api/posts.ts')).toEqual(['/api/posts']); + expect(routeNames('src/pages/rss.xml.js')).toEqual(['/rss.xml']); + }); + + it('excludes underscore-prefixed segments and config files', () => { + expect(routeNames('src/pages/_partial.astro')).toEqual([]); + expect(routeNames('src/pages/blog/_components/Card.astro')).toEqual([]); + expect(routeNames('src/pages/vite.config.ts')).toEqual([]); + }); + + it('ignores .astro files outside src/pages', () => { + expect(routeNames('src/components/Button.astro')).toEqual([]); + expect(routeNames('docs/pages/guide.astro')).toEqual([]); + }); +}); + +describe('astroResolver.resolve — Astro global and virtual modules', () => { + const ctx = {} as never; + const baseRef = { + fromNodeId: 'component:a', + line: 1, + column: 0, + filePath: 'src/pages/index.astro', + language: 'astro', + }; + + it('claims Astro.* global references as framework-provided', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'Astro.props', referenceKind: 'references' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + expect(res?.confidence).toBe(1.0); + }); + + it('claims astro:content virtual module imports', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astro:content', referenceKind: 'imports' } as never, + ctx + ); + expect(res?.resolvedBy).toBe('framework'); + }); + + it('leaves ordinary names alone', () => { + const res = astroResolver.resolve( + { ...baseRef, referenceName: 'astrolabe', referenceKind: 'calls' } as never, + { getNodesByName: () => [] } as never + ); + expect(res).toBeNull(); + }); +}); + // Regression tests: commented-out and docstring route examples must NOT // surface as phantom route nodes. These would have failed before the // strip-comments wiring (the regex would happily scan comments/docstrings). @@ -969,7 +1571,7 @@ Route::get('/real', [RealController::class, 'index']); `; const { nodes, references } = laravelResolver.extract!('routes/web.php', src); expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['index']); + expect(references.map((r) => r.referenceName)).toEqual(['RealController@index']); }); it('rails: skips =begin/=end and # commented routes', () => { @@ -982,7 +1584,7 @@ get '/real', to: 'real#index' `; const { nodes, references } = railsResolver.extract!('config/routes.rb', src); expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['index']); + expect(references.map((r) => r.referenceName)).toEqual(['real#index']); }); it('spring: skips // and /* */ commented @GetMapping', () => { @@ -1046,7 +1648,7 @@ public IActionResult ListUsers() { return Ok(); } app.get("real", use: listUsers) `; const { nodes, references } = vaporResolver.extract!('routes.swift', src); - expect(nodes.map((n) => n.name)).toEqual(['GET real']); + expect(nodes.map((n) => n.name)).toEqual(['GET /real']); expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); }); diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts new file mode 100644 index 000000000..993b68614 --- /dev/null +++ b/__tests__/function-ref.test.ts @@ -0,0 +1,790 @@ +/** + * Function-as-value capture tests (#756) — registration-linking for callbacks. + * + * A function name used as a VALUE (passed as an argument, assigned to a + * field/function pointer, placed in a struct/object initializer or function + * table) must produce a `references` edge from the registration site to the + * function, so `callers`/`impact` surface where a callback is wired up. + * + * Safety properties verified here, per the dynamic-dispatch discipline + * ("a wrong edge is worse than none"): + * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge + * - same-file priority: a same-file definition beats a same-named decoy + * - kind filter: a class/variable passed as a value never gets a + * function-ref edge + * - self: a function passing itself → no self-loop + * - drain: all resolvable function_ref rows leave unresolved_refs (no + * batched-resolver runaway), and re-index is idempotent + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import type { Edge } from '../src/types'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +/** Incoming edges to `name`'s node that came from function-as-value capture. */ +function fnRefEdgesInto(cg: CodeGraph, name: string): Edge[] { + const targets = cg.getNodesByName(name); + const edges: Edge[] = []; + for (const t of targets) { + for (const e of cg.getIncomingEdges(t.id)) { + if (e.kind === 'references' && e.metadata?.fnRef === true) { + edges.push(e); + } + } + } + return edges; +} + +/** Names of the source nodes of the given edges, sorted. */ +function sourceNames(cg: CodeGraph, edges: Edge[]): string[] { + const names: string[] = []; + for (const e of edges) { + const n = cg.getNode(e.source); + if (n) names.push(n.name); + } + return names.sort(); +} + +describe('Function-as-value capture (#756)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('C: registration sites produce references edges (the #756 scenario)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-c-')); + fs.writeFileSync( + path.join(tmpDir, 'driver.c'), + [ + 'struct ops { void (*recv_cb)(int); void (*send_cb)(int); };', + 'typedef void (*cb_t)(int);', + '', + 'static void my_recv_cb(int x) { (void)x; }', + 'static void my_send_cb(int x) { (void)x; }', + '', + 'void register_handler(void (*cb)(int)) { cb(1); }', + '', + 'void direct_caller(void) { my_recv_cb(5); }', + '', + 'void arg_registrar(void) { register_handler(my_recv_cb); }', + 'void addr_registrar(void) { register_handler(&my_recv_cb); }', + 'void assign_registrar(struct ops *o) { o->recv_cb = my_recv_cb; }', + '', + 'static struct ops global_ops = { .recv_cb = my_recv_cb, .send_cb = my_send_cb };', + 'static cb_t cb_table[] = { my_recv_cb, my_send_cb };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const intoRecv = fnRefEdgesInto(cg, 'my_recv_cb'); + expect(sourceNames(cg, intoRecv)).toEqual([ + 'addr_registrar', + 'arg_registrar', + 'assign_registrar', + 'driver.c', // file-scope: designated init + positional table (deduped per source) + ]); + + // The direct call is still a `calls` edge — unchanged by this feature. + const recv = cg.getNodesByName('my_recv_cb')[0]!; + const callEdges = cg + .getIncomingEdges(recv.id) + .filter((e) => e.kind === 'calls'); + expect(sourceNames(cg, callEdges)).toEqual(['direct_caller']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('TypeScript: arg / object / array / member / assignment forms', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ts-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export function targetCb(x: number): void { console.log(x); }', + 'function registerHandler(cb: (x: number) => void): void { cb(1); }', + '', + 'export function argRegistrar(): void { registerHandler(targetCb); }', + 'export function timerRegistrar(): void { setTimeout(targetCb, 100); }', + 'export function objRegistrar(): unknown { return { recv: targetCb }; }', + 'export function arrRegistrar(): unknown { return [targetCb]; }', + '', + 'class Emitter { cb: ((x: number) => void) | null = null; }', + 'export function assignRegistrar(e: Emitter): void { e.cb = targetCb; }', + '', + 'interface Btn { on(ev: string, cb: () => void): void; }', + 'export class Comp {', + ' handleClick(): void {}', + ' wire(btn: Btn): void { btn.on("click", this.handleClick); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'targetCb'))).toEqual([ + 'argRegistrar', + 'arrRegistrar', + 'assignRegistrar', + 'objRegistrar', + 'timerRegistrar', + ]); + // `this.handleClick` resolves class-scoped (#808): the target must be a + // method of the ENCLOSING class, in the same file. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('resolves an imported callback across files via its import', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.ts'), + 'export function onMessage(x: number): void { console.log(x); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'wiring.ts'), + [ + "import { onMessage } from './handlers';", + 'export function wire(bus: { on(cb: (x: number) => void): void }): void {', + ' bus.on(onMessage);', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'onMessage'); + expect(sourceNames(cg, edges)).toContain('wire'); + // The edge must target the handlers.ts definition. + const target = cg.getNode(edges[0]!.target); + expect(target?.filePath.endsWith('handlers.ts')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DECOY: ambiguous cross-file name without an import resolves to NO edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-decoy-')); + // Two same-named functions in different files… + fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export function process(x: number): void {}\n'); + fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'export function process(x: number): void {}\n'); + // …and a registrar that names `process` WITHOUT importing it. The name + // still passes the extraction gate only if imported/defined here — it is + // neither, so this asserts the gate; even if it leaked through, the + // ambiguity rule (unique-only cross-file) must yield no edge. + fs.writeFileSync( + path.join(tmpDir, 'c.ts'), + 'export function wire(bus: { on(cb: unknown): void }, process: unknown): void { bus.on(process); }\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'process'); + expect(sourceNames(cg, edges)).not.toContain('wire'); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SAME-FILE PRIORITY: a same-file definition beats a same-named decoy elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-samefile-')); + fs.writeFileSync(path.join(tmpDir, 'decoy.c'), 'void my_cb(int x) { (void)x; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'real.c'), + [ + 'static void my_cb(int x) { (void)x; }', + 'void register_handler(void (*cb)(int)) { cb(1); }', + 'void wire(void) { register_handler(my_cb); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const wires = fnRefEdgesInto(cg, 'my_cb').filter((e) => { + const src = cg.getNode(e.source); + return src?.name === 'wire'; + }); + expect(wires).toHaveLength(1); + const target = cg.getNode(wires[0]!.target); + expect(target?.filePath.endsWith('real.c')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KIND FILTER: a class passed as a value gets no function-ref edge', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-kind-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'export class Strategy { run(): void {} }', + 'export function consume(x: unknown): void { void x; }', + 'export function wire(): void { consume(Strategy); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const strategy = cg.getNodesByName('Strategy').find((n) => n.kind === 'class')!; + const fnRef = cg + .getIncomingEdges(strategy.id) + .filter((e) => e.metadata?.fnRef === true); + expect(fnRef).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SELF: a function registering itself produces no self-loop', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-self-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare function schedule(cb: () => void): void;', + 'export function retry(): void { schedule(retry); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const retry = cg.getNodesByName('retry')[0]!; + const selfLoops = cg + .getIncomingEdges(retry.id) + .filter((e) => e.source === retry.id && e.metadata?.fnRef === true); + expect(selfLoops).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C++: &Cls::method member pointers resolve scoped; bare ids are free-function-only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-cpp-')); + fs.writeFileSync( + path.join(tmpDir, 'widget.cpp'), + [ + 'struct Widget {', + ' void on_click(int x);', + '};', + 'void Widget::on_click(int x) { (void)x; }', + 'struct Decoy {', + ' void on_click(int x);', + '};', + 'void Decoy::on_click(int x) { (void)x; }', + 'void free_cb(int x) { (void)x; }', + 'void bare_fn(int x) { (void)x; }', + 'void reg(void* p) { (void)p; }', + 'void wire() {', + ' auto p = &Widget::on_click;', // qualified — must hit Widget, not Decoy + ' reg(p);', + ' reg(&free_cb);', // explicit address-of — captured + ' reg(bare_fn);', // bare id in args — NOT captured for C++ (addressOfOnly) + '}', + // A method named like a local: passing the LOCAL must not resolve to + // the method (cpp args accept only explicit & forms). + 'struct Buf { char* out(); };', + 'void copy_to(void* out_) { (void)out_; }', + 'void caller(char* out) { copy_to(out); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Qualified member pointer resolves to Widget::on_click specifically. + const onClicks = cg.getNodesByName('on_click'); + const widgetOnClick = onClicks.find((n) => n.qualifiedName.includes('Widget'))!; + const decoyOnClick = onClicks.find((n) => n.qualifiedName.includes('Decoy'))!; + const intoWidget = cg + .getIncomingEdges(widgetOnClick.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoWidget).toHaveLength(1); + expect(cg.getNode(intoWidget[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyOnClick.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // Explicit &fn resolves; bare identifier in C++ args does NOT (the + // generic-name collision class: fmt's `begin`/`out`/`size` params). + expect(sourceNames(cg, fnRefEdgesInto(cg, 'free_cb'))).toContain('wire'); + expect(fnRefEdgesInto(cg, 'bare_fn')).toHaveLength(0); + + // The local `out` param must NOT produce an edge to Buf::out. + const outMethod = cg.getNodesByName('out').find((n) => n.kind === 'method'); + if (outMethod) { + expect( + cg.getIncomingEdges(outMethod.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('Pascal: := event wiring, @addr and bare args', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pas-')); + fs.writeFileSync( + path.join(tmpDir, 'main.pas'), + [ + 'unit Main;', + 'interface', + 'type', + ' TCallback = procedure(X: Integer);', + ' THolder = class', + ' public', + ' OnFire: TCallback;', + ' procedure Wire;', + ' end;', + 'procedure TargetCb(X: Integer);', + 'procedure RegisterHandler(Cb: TCallback);', + 'procedure ArgRegistrar;', + 'procedure AddrRegistrar;', + 'implementation', + 'procedure TargetCb(X: Integer);', + 'begin', + ' WriteLn(X);', + 'end;', + 'procedure RegisterHandler(Cb: TCallback);', + 'begin', + ' Cb(1);', + 'end;', + 'procedure ArgRegistrar;', + 'begin', + ' RegisterHandler(TargetCb);', + 'end;', + 'procedure AddrRegistrar;', + 'begin', + ' RegisterHandler(@TargetCb);', + 'end;', + 'procedure THolder.Wire;', + 'begin', + ' OnFire := TargetCb;', + 'end;', + 'end.', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'TargetCb'))).toEqual([ + 'AddrRegistrar', + 'ArgRegistrar', + 'Wire', + ]); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-')); + fs.writeFileSync( + path.join(tmpDir, 'main.ts'), + [ + 'declare const bus: { on(ev: string, cb: () => void): void };', + // Decoy: a same-named method on an UNRELATED class. + 'export class Decoy { refresh(): void {} }', + 'export class Panel {', + ' views: number[] = [];', // property (post-#808), shares no name + ' refresh(): void {}', + ' wire(): void {', + ' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh + ' bus.on("data", this.views as never);', // property → NO edge + ' bus.on("gone", this.missing as never);', // unknown member → NO edge + ' }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const refreshes = cg.getNodesByName('refresh'); + const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!; + const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!; + + const intoPanel = cg + .getIncomingEdges(panelRefresh.id) + .filter((e) => e.metadata?.fnRef === true); + expect(intoPanel).toHaveLength(1); + expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + + // The property and the unknown member produce nothing. + const views = cg.getNodesByName('views').find((n) => n.kind === 'property'); + if (views) { + expect( + cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-')); + fs.writeFileSync( + path.join(tmpDir, 'base.ts'), + 'export class FormBase { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'unrelated.ts'), + 'export class Unrelated { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'login.ts'), + [ + "import { FormBase } from './base';", + 'declare const bus: { on(ev: string, cb: () => void): void };', + 'export class LoginForm extends FormBase {', + ' wire(): void { bus.on("submit", this.handleSubmit); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handleSubmits = cg.getNodesByName('handleSubmit'); + const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!; + const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!; + + const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true); + expect(intoBase).toHaveLength(1); + expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Handlers.java'), + [ + 'package com.example;', + 'public class Handlers {', + ' public static void onMessage(int x) { System.out.println(x); }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'BaseForm.java'), + ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Main.java'), + [ + 'package com.example;', + 'import com.example.Handlers;', + 'import java.util.function.IntConsumer;', + 'public class Main extends BaseForm {', + ' static void registerHandler(IntConsumer cb) { cb.accept(1); }', + ' void run0() {}', + ' void crossFile() { registerHandler(Handlers::onMessage); }', + ' void thisRef() { registerHandler(this::run0); }', + ' void superRef() { registerHandler(super::baseHandler); }', + ' void varRef(Main m) { registerHandler(m::run0); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']); + // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT + // add a second edge — exactly one source. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('KOTLIN: companion-object refs resolve cross-file without imports; decoy companion untouched', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ktcomp-')); + // Same package, no imports — the Java/Kotlin reality the name gate can't + // see, which is why qualified `Type::member` candidates skip it. + fs.writeFileSync( + path.join(tmpDir, 'Handlers.kt'), + [ + 'class KtHandlers {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + 'class Decoy {', + ' companion object {', + ' fun handle(x: Int) {}', + ' }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Wirer.kt'), + [ + 'fun register(cb: Any) {}', + 'class Wirer {', + ' fun wire() { register(KtHandlers::handle) }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handles = cg.getNodesByName('handle'); + const target = handles.find((n) => n.qualifiedName.includes('KtHandlers'))!; + const decoy = handles.find((n) => n.qualifiedName.includes('Decoy'))!; + const into = cg.getIncomingEdges(target.id).filter((e) => e.metadata?.fnRef === true); + expect(into).toHaveLength(1); + expect(cg.getNode(into[0]!.source)?.name).toBe('wire'); + expect(cg.getIncomingEdges(decoy.id).filter((e) => e.metadata?.fnRef === true)).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-')); + fs.writeFileSync( + path.join(tmpDir, 'main.swift'), + [ + 'func register(_ cb: (Int) -> Void) { cb(1) }', + 'class Monitor {', + ' func report(_ x: Int) {}', + ' func wire() { register(report) }', // implicit self → Monitor::report + '}', + 'class Other {', + // `report` here is a PARAMETER; Monitor::report must not win. + ' func use(report: (Int) -> Void) { register(report) }', + '}', + 'func topLevel() { register(report) }', // no implicit self → no method target + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'report'); + expect(sourceNames(cg, edges)).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-')); + // Handler defined in its own file… + fs.writeFileSync(path.join(tmpDir, 't_string.c'), 'void getCommand(int c) { (void)c; }\n'); + // …and registered in a table in ANOTHER file, with no import mechanism (C). + fs.writeFileSync( + path.join(tmpDir, 'server.c'), + [ + 'struct cmd { const char *name; void (*proc)(int); };', + 'static struct cmd commandTable[] = {', + ' { "get", getCommand },', + '};', + ].join('\n') + ); + // Ambiguity safety: two files define dupCmd; a third table references it → + // NO edge (unique-or-drop). + fs.writeFileSync(path.join(tmpDir, 'dup_a.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync(path.join(tmpDir, 'dup_b.c'), 'void dupCmd(int c) { (void)c; }\n'); + fs.writeFileSync( + path.join(tmpDir, 'other.c'), + [ + 'struct cmd2 { void (*proc)(int); };', + 'static struct cmd2 otherTable[] = { { dupCmd } };', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // Cross-file unique handler resolves from the table's file. + const intoGet = fnRefEdgesInto(cg, 'getCommand'); + expect(sourceNames(cg, intoGet)).toEqual(['server.c']); + const target = cg.getNode(intoGet[0]!.target); + expect(target?.filePath.endsWith('t_string.c')).toBe(true); + + // Ambiguous handler resolves to NOTHING — silent beats wrong. + expect(fnRefEdgesInto(cg, 'dupCmd')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('PHP: HOF string callables, [$this,…] and [Cls::class,…] arrays; non-HOF strings ignored', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-php-')); + fs.writeFileSync( + path.join(tmpDir, 'handlers.php'), + " $b; }\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'main.php'), + [ + ' { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-rubyhooks-')); + fs.writeFileSync( + path.join(tmpDir, 'posts_controller.rb'), + [ + 'class ApplicationController', + ' def authenticate; end', + 'end', + '', + 'class PostsController < ApplicationController', + ' before_action :authenticate', // inherited → ApplicationController + ' after_save :reindex', + ' validates :title, presence: true', // attributes, NOT methods → no edge + ' rescue_from StandardError, with: :render_500', + '', + ' def reindex; end', + ' def render_500; end', + ' def title; end', + 'end', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + const auth = fnRefEdgesInto(cg, 'authenticate'); + expect(auth).toHaveLength(1); + expect(cg.getNode(auth[0]!.target)?.qualifiedName).toContain('ApplicationController'); + + expect(fnRefEdgesInto(cg, 'reindex')).toHaveLength(1); + expect(fnRefEdgesInto(cg, 'render_500')).toHaveLength(1); + // `validates :title` names an attribute — the same-named METHOD must + // get no registration edge. + expect(fnRefEdgesInto(cg, 'title')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-')); + fs.writeFileSync( + path.join(tmpDir, 'main.c'), + [ + 'static void cb_a(int x) { (void)x; }', + 'void reg(void (*cb)(int)) { cb(1); }', + 'void wire(void) { reg(cb_a); }', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const stats1 = cg.getStats(); + + // No function_ref rows may linger for resolvable names — the batched + // resolver must have drained them (delete keyed on the ORIGINAL stored + // ref; the #760 runaway came from violating that). + const db = (cg as unknown as { db: { prepare(sql: string): { all(): unknown[] } } }).db; + let leftover: unknown[] = []; + try { + leftover = db + .prepare("SELECT * FROM unresolved_refs WHERE reference_kind = 'function_ref'") + .all(); + } catch { + // If internals aren't reachable this guard is covered by the edge + // assertions below. + } + expect(leftover).toHaveLength(0); + + // Re-index: identical node/edge counts (idempotent, no accumulation). + await cg.indexAll(); + const stats2 = cg.getStats(); + expect(stats2.totalNodes).toBe(stats1.totalNodes); + expect(stats2.totalEdges).toBe(stats1.totalEdges); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'cb_a'))).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); +}); diff --git a/__tests__/generated-detection.test.ts b/__tests__/generated-detection.test.ts new file mode 100644 index 000000000..90bbae7f1 --- /dev/null +++ b/__tests__/generated-detection.test.ts @@ -0,0 +1,47 @@ +/** + * Regression coverage for the generated-file detector that drives + * symbol-disambiguation down-ranking. Locked here because the suffix + * list is a contract: if a future edit drops `.pb.go`, the cosmos-sdk + * trace endpoint regresses to the gRPC stub (see + * `project_go_multi_module_audit` memory + the audit in #N/A). + */ + +import { describe, it, expect } from 'vitest'; +import { isGeneratedFile } from '../src/extraction/generated-detection'; + +describe('isGeneratedFile', () => { + it('classifies Go protobuf / gRPC / pulsar / mock outputs as generated', () => { + expect(isGeneratedFile('api/cosmos/bank/v1beta1/tx_grpc.pb.go')).toBe(true); + expect(isGeneratedFile('x/bank/types/tx.pb.go')).toBe(true); + expect(isGeneratedFile('api/cosmos/bank/v1beta1/tx.pulsar.go')).toBe(true); + // cosmos-sdk uses `_mocks.go`; mockgen's default is `mock_.go`; + // many projects use `_mock.go`. All three are mockgen output. + expect(isGeneratedFile('x/auth/testutil/expected_keepers_mocks.go')).toBe(true); + expect(isGeneratedFile('internal/foo_mock.go')).toBe(true); + expect(isGeneratedFile('mock_keeper.go')).toBe(true); + }); + + it('does not flag the hand-written keeper as generated', () => { + expect(isGeneratedFile('x/bank/keeper/msg_server.go')).toBe(false); + expect(isGeneratedFile('x/bank/keeper/send.go')).toBe(false); + }); + + it('catches common cross-language codegen suffixes', () => { + expect(isGeneratedFile('app/foo.generated.ts')).toBe(true); + expect(isGeneratedFile('app/foo.generated.tsx')).toBe(true); + expect(isGeneratedFile('proto/bar_pb2.py')).toBe(true); + expect(isGeneratedFile('proto/bar_pb2_grpc.py')).toBe(true); + expect(isGeneratedFile('lib/baz.pb.cc')).toBe(true); + expect(isGeneratedFile('lib/baz.pb.h')).toBe(true); + expect(isGeneratedFile('lib/quux.g.dart')).toBe(true); + expect(isGeneratedFile('lib/quux.freezed.dart')).toBe(true); + }); + + it('leaves ordinary source files alone', () => { + expect(isGeneratedFile('src/index.ts')).toBe(false); + expect(isGeneratedFile('src/components/Foo.tsx')).toBe(false); + expect(isGeneratedFile('lib/main.dart')).toBe(false); + expect(isGeneratedFile('cmd/server/main.go')).toBe(false); + expect(isGeneratedFile('app/db.py')).toBe(false); + }); +}); diff --git a/__tests__/gin-middleware-chain.test.ts b/__tests__/gin-middleware-chain.test.ts new file mode 100644 index 000000000..b3f0ae061 --- /dev/null +++ b/__tests__/gin-middleware-chain.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +/** + * End-to-end synthesizer test for the gin middleware chain. + * + * `(*Context).Next` runs the handler chain by slice index + * (`c.handlers[c.index](c)`) — a computed dispatch tree-sitter can't resolve, so + * `callees(Next)` would otherwise dead-end at the `len()` helper. Handlers are + * registered via `.Use(...)` / `.GET("/path", h)`. Verify the synthesizer links + * `Next` → each registered NAMED HandlerFunc, captures the wiring site, and + * skips inline (anonymous) closures. + */ +describe('gin middleware-chain synthesizer', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gin-chain-fixture-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('links Context.Next to handlers registered via Use/GET and skips inline closures', async () => { + fs.writeFileSync(path.join(dir, 'go.mod'), 'module ginapp\n\ngo 1.21\n'); + + // gin-core shape: the dynamic-dispatch chain driver + registration surface. + fs.writeFileSync( + path.join(dir, 'gin.go'), + `package gin + +type HandlerFunc func(*Context) +type HandlersChain []HandlerFunc + +type Context struct { + handlers HandlersChain + index int8 +} + +func (c *Context) Next() { + c.index++ + for c.index < int8(len(c.handlers)) { + c.handlers[c.index](c) + c.index++ + } +} + +type Engine struct { + Handlers HandlersChain +} + +func (e *Engine) Use(middleware ...HandlerFunc) { + e.Handlers = append(e.Handlers, middleware...) +} + +func (e *Engine) GET(path string, handlers ...HandlerFunc) {} +` + ); + + // registration site: named middleware + named route handler + an inline closure. + fs.writeFileSync( + path.join(dir, 'app.go'), + `package gin + +func Logger(c *Context) {} +func Recovery(c *Context) {} +func getUser(c *Context) {} + +func setup() { + e := &Engine{} + e.Use(Logger, Recovery) + e.GET("/users", getUser) + e.GET("/inline", func(c *Context) {}) +} +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.name source_name, s.kind source_kind, t.name target_name, + json_extract(e.metadata,'$.via') via, + json_extract(e.metadata,'$.registeredAt') registeredAt + FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'gin-middleware-chain'` + ) + .all(); + cg.close?.(); + + // Every edge originates from the chain dispatcher Context.Next. + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r: any) => r.source_name === 'Next' && r.source_kind === 'method')).toBe(true); + + // Exactly the three NAMED handlers are linked — the inline closure (4th + // registration) is anonymous and must be skipped. + const targets = new Set(rows.map((r: any) => r.target_name)); + expect(targets).toEqual(new Set(['Logger', 'Recovery', 'getUser'])); + + // The wiring site (`.Use`/`.GET` call) is surfaced for the agent. + const logger = rows.find((r: any) => r.target_name === 'Logger'); + expect(logger.via).toBe('Logger'); + expect(logger.registeredAt).toMatch(/app\.go:\d+/); + }); +}); diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 7c771af0b..bc25942ac 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -293,6 +293,25 @@ export { main }; expect(Array.isArray(callees)).toBe(true); }); + + it('treats class instantiation as a caller/callee of the class (#774)', () => { + // main() does `new DerivedClass(10, 'test')`. Constructing a class is + // calling its constructor, so main is a caller of DerivedClass and + // DerivedClass is a callee of main. Before #774 the `instantiates` edge + // was excluded from the caller/callee traversal, so `callers ` + // returned the importing file (or nothing) and missed every + // construction site. + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + const main = cg.getNodesByKind('function').find((n) => n.name === 'main'); + expect(derived).toBeDefined(); + expect(main).toBeDefined(); + + const callerNames = cg.getCallers(derived!.id).map((c) => c.node.name); + expect(callerNames).toContain('main'); + + const calleeNames = cg.getCallees(main!.id).map((c) => c.node.name); + expect(calleeNames).toContain('DerivedClass'); + }); }); describe('getImpactRadius()', () => { @@ -309,6 +328,19 @@ export { main }; expect(impact.nodes.size).toBeGreaterThan(0); expect(impact.nodes.has(formatValue.id)).toBe(true); }); + + it('does not drag in sibling members via the structural contains edge (#536)', () => { + const getName = cg.getNodesByKind('method').find((n) => n.name === 'getName'); + const derived = cg.getNodesByKind('class').find((n) => n.name === 'DerivedClass'); + expect(getName).toBeDefined(); + expect(derived).toBeDefined(); + + const impact = cg.getImpactRadius(getName!.id, 3); + // The containing class must NOT be pulled into impact just because it + // *contains* getName — climbing that contains edge would re-expand every + // sibling method and explode impact for a leaf symbol. (#536) + expect(impact.nodes.has(derived!.id)).toBe(false); + }); }); describe('findPath()', () => { @@ -375,16 +407,37 @@ export { main }; }); describe('File dependency analysis', () => { - it('should get file dependencies', () => { + // Regression: getFileDependents/getFileDependencies used to follow + // ONLY `imports` edges, which in this engine are same-file (a file → its + // own local import declarations). That made both return [] for EVERY file, + // so `codegraph affected` found no dependents on any language/framework. + // They must follow the cross-file symbol graph instead (calls / references + // / instantiates / extends / implements / ...). + it('reports cross-file dependencies via the symbol graph, not just imports', () => { const deps = cg.getFileDependencies('src/main.ts'); + // main() instantiates DerivedClass (derived.ts) and calls + // processValue/doubleValue (utils.ts) — both are real dependencies. + expect(deps).toContain('src/utils.ts'); + expect(deps).toContain('src/derived.ts'); + }); - expect(Array.isArray(deps)).toBe(true); + it('reports cross-file dependents via the symbol graph, not just imports', () => { + // utils.ts is used by main.ts (processValue/doubleValue calls); the old + // imports-only implementation returned [] here. + expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts'); }); - it('should get file dependents', () => { - const dependents = cg.getFileDependents('src/utils.ts'); + it('counts extends/implements as a dependency edge', () => { + // derived.ts extends BaseClass / implements Printable, both in base.ts. + expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts'); + expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts'); + }); - expect(Array.isArray(dependents)).toBe(true); + it('never lists a file as its own dependent or dependency', () => { + for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) { + expect(cg.getFileDependents(f)).not.toContain(f); + expect(cg.getFileDependencies(f)).not.toContain(f); + } }); }); diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index d2ee23e50..eed76515e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -19,7 +19,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; +import { uninstallTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; +import { cleanupLegacyHooks } from '../src/installer/targets/claude'; function mkTmpDir(label: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); @@ -30,17 +32,41 @@ function mkTmpDir(label: string): string { // `os.homedir()` reads first. Same trick the rest of the suite uses // when it needs a mock home. function setHome(dir: string): { restore: () => void } { - const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + const prev = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + APPDATA: process.env.APPDATA, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + HERMES_HOME: process.env.HERMES_HOME, + }; process.env.HOME = dir; process.env.USERPROFILE = dir; + process.env.APPDATA = path.join(dir, '.config'); + process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); + delete process.env.HERMES_HOME; return { restore() { if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE; + if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA; + if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; + if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; }, }; } +// A marker-delimited CodeGraph block exactly as a previous installer +// wrote it. Issue #529: the installer no longer writes an instructions +// file, but install (self-heal on upgrade) and uninstall both still +// strip a block a prior install left, so we plant this to exercise it. +const LEGACY_BLOCK = [ + '', + '## CodeGraph', + '', + 'Prefer `codegraph_search` / `codegraph_callers` over grep.', + '', +].join('\n'); + describe('Installer targets — contract', () => { let tmpHome: string; let tmpCwd: string; @@ -166,23 +192,39 @@ describe('Installer targets — partial-state idempotency', () => { fs.rmSync(tmpCwd, { recursive: true, force: true }); }); - it('codex: install after only config.toml exists — second pass is fully unchanged', () => { + it('codex: install writes config.toml AND the AGENTS.md codegraph block (#704)', () => { const codex = getTarget('codex')!; - // First install creates both files. - codex.install('global', { autoAllow: false }); - // Delete the AGENTS.md to simulate partial state (user wiped one file). + const first = codex.install('global', { autoAllow: false }); const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md'); + expect(first.files.some((f) => f.path.endsWith('config.toml'))).toBe(true); + // The short instructions block IS written (subagents / non-MCP + // harnesses read AGENTS.md but never the MCP initialize instructions). expect(fs.existsSync(agentsMd)).toBe(true); - fs.unlinkSync(agentsMd); - // Reinstall — TOML stays unchanged, AGENTS.md is recreated. + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + // Re-install is fully unchanged (byte-equal block → idempotent). const second = codex.install('global', { autoAllow: false }); - const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!; - const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!; - expect(tomlEntry.action).toBe('unchanged'); - expect(mdEntry.action).toBe('created'); - // Third install — both unchanged (full idempotency restored). - const third = codex.install('global', { autoAllow: false }); - for (const f of third.files) expect(f.action).toBe('unchanged'); + for (const f of second.files) expect(f.action).toBe('unchanged'); + }); + + it('codex: install replaces a legacy AGENTS.md codegraph block with the current one, keeping user content', () => { + const codex = getTarget('codex')!; + const dir = path.join(tmpHome, '.codex'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `# My codex notes\n\nBe terse.\n\n${LEGACY_BLOCK}\n`); + + const result = codex.install('global', { autoAllow: false }); + + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My codex notes'); + expect(body).toContain('Be terse.'); + // Self-heal: the stale pre-#529 body is gone, the current block is in. + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + const mdEntry = result.files.find((f) => f.path.endsWith('AGENTS.md')); + expect(mdEntry?.action).toBe('updated'); }); it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { @@ -252,55 +294,536 @@ describe('Installer targets — partial-state idempotency', () => { expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); }); - it('opencode: install writes AGENTS.md with the marker-delimited codegraph block', () => { + it('opencode: install writes the AGENTS.md codegraph block (#704)', () => { const opencode = getTarget('opencode')!; - opencode.install('global', { autoAllow: true }); + const result = opencode.install('global', { autoAllow: true }); const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); expect(fs.existsSync(agentsMd)).toBe(true); - const body = fs.readFileSync(agentsMd, 'utf-8'); - expect(body).toContain(''); - expect(body).toContain(''); - expect(body).toContain('codegraph_callers'); + expect(fs.readFileSync(agentsMd, 'utf-8')).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('created'); }); - it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => { + it('opencode: install replaces a legacy AGENTS.md codegraph block, preserving user content', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); fs.mkdirSync(dir, { recursive: true }); const agentsMd = path.join(dir, 'AGENTS.md'); - fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n'); + fs.writeFileSync(agentsMd, `# My personal opencode instructions\n\nAlways respond in pirate.\n\n${LEGACY_BLOCK}\n`); + + const result = opencode.install('global', { autoAllow: true }); - opencode.install('global', { autoAllow: true }); const body = fs.readFileSync(agentsMd, 'utf-8'); expect(body).toContain('# My personal opencode instructions'); expect(body).toContain('Always respond in pirate.'); - expect(body).toContain(''); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('AGENTS.md'))?.action).toBe('updated'); }); - it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => { + it('opencode: uninstall strips a leftover codegraph block from AGENTS.md, keeping user content', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); fs.mkdirSync(dir, { recursive: true }); const agentsMd = path.join(dir, 'AGENTS.md'); - fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n'); + fs.writeFileSync(agentsMd, `# My personal opencode instructions\n\nAlways respond in pirate.\n\n${LEGACY_BLOCK}\n`); - opencode.install('global', { autoAllow: true }); opencode.uninstall('global'); const body = fs.readFileSync(agentsMd, 'utf-8'); expect(body).toContain('# My personal opencode instructions'); expect(body).toContain('Always respond in pirate.'); expect(body).not.toContain('CODEGRAPH_START'); - expect(body).not.toContain('codegraph_callers'); }); - it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => { + it('opencode: local install writes ./opencode.jsonc and the ./AGENTS.md block (#704)', () => { const opencode = getTarget('opencode')!; const result = opencode.install('local', { autoAllow: true }); - const paths = result.files.map((f) => f.path); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); // macOS realpath shenanigans (/var vs /private/var) — suffix match. expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true); expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true); + }); + + it('gemini: install writes settings.json (mcpServers.codegraph) and the GEMINI.md block (#704)', () => { + const gemini = getTarget('gemini')!; + const result = gemini.install('global', { autoAllow: true }); + const settings = path.join(tmpHome, '.gemini', 'settings.json'); + const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); + expect(result.files.some((f) => f.path === settings)).toBe(true); + expect(result.files.some((f) => f.path === geminiMd)).toBe(true); + expect(fs.existsSync(geminiMd)).toBe(true); + expect(fs.readFileSync(geminiMd, 'utf-8')).toContain('codegraph explore'); + + const cfg = JSON.parse(fs.readFileSync(settings, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + }); + + it('gemini: install preserves pre-existing settings (security.auth survives)', () => { + const gemini = getTarget('gemini')!; + const settings = path.join(tmpHome, '.gemini', 'settings.json'); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync(settings, JSON.stringify({ + security: { auth: { selectedType: 'oauth-personal' } }, + }, null, 2) + '\n'); + + gemini.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(settings, 'utf-8')); + expect(after.security?.auth?.selectedType).toBe('oauth-personal'); + expect(after.mcpServers?.codegraph).toBeDefined(); + }); + + it('gemini: uninstall strips codegraph but leaves pre-existing settings (security.auth) intact', () => { + const gemini = getTarget('gemini')!; + const settings = path.join(tmpHome, '.gemini', 'settings.json'); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync(settings, JSON.stringify({ + security: { auth: { selectedType: 'oauth-personal' } }, + }, null, 2) + '\n'); + + gemini.install('global', { autoAllow: true }); + gemini.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(settings, 'utf-8')); + expect(after.security?.auth?.selectedType).toBe('oauth-personal'); + expect(after.mcpServers).toBeUndefined(); + }); + + it('gemini: local install writes ./.gemini/settings.json and the project-root ./GEMINI.md block (#704)', () => { + const gemini = getTarget('gemini')!; + const result = gemini.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.gemini/settings.json'))).toBe(true); + expect(paths.some((p) => p.endsWith('/GEMINI.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'GEMINI.md'))).toBe(true); + }); + + it('gemini: uninstall strips a leftover GEMINI.md codegraph block, keeping user content', () => { + const gemini = getTarget('gemini')!; + const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); + fs.mkdirSync(path.dirname(geminiMd), { recursive: true }); + fs.writeFileSync(geminiMd, `# My personal Gemini context\n\nAlways respond concisely.\n\n${LEGACY_BLOCK}\n`); + + gemini.uninstall('global'); + + const body = fs.readFileSync(geminiMd, 'utf-8'); + expect(body).toContain('# My personal Gemini context'); + expect(body).toContain('Always respond concisely.'); + expect(body).not.toContain('CODEGRAPH_START'); + }); + + it('kiro: install writes settings/mcp.json (mcpServers.codegraph) and no steering doc (#529)', () => { + const kiro = getTarget('kiro')!; + const result = kiro.install('global', { autoAllow: true }); + const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json'); + const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + expect(result.files.some((f) => f.path === mcp)).toBe(true); + expect(result.files.some((f) => f.path === steering)).toBe(false); + expect(fs.existsSync(steering)).toBe(false); + + const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + }); + + it('kiro: install deletes a leftover steering codegraph.md (self-heal) (#529)', () => { + const kiro = getTarget('kiro')!; + const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + fs.mkdirSync(path.dirname(steering), { recursive: true }); + fs.writeFileSync(steering, `${LEGACY_BLOCK}\n`); + + const result = kiro.install('global', { autoAllow: true }); + expect(fs.existsSync(steering)).toBe(false); + expect(result.files.find((f) => f.path === steering)?.action).toBe('removed'); + }); + + it('kiro: install preserves a pre-existing sibling MCP server in mcp.json', () => { + const kiro = getTarget('kiro')!; + const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + kiro.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('kiro: uninstall strips codegraph but leaves sibling MCP servers intact', () => { + const kiro = getTarget('kiro')!; + const mcp = path.join(tmpHome, '.kiro', 'settings', 'mcp.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + kiro.install('global', { autoAllow: true }); + kiro.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('kiro: uninstall removes a leftover steering codegraph.md file outright', () => { + const kiro = getTarget('kiro')!; + const steering = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + fs.mkdirSync(path.dirname(steering), { recursive: true }); + fs.writeFileSync(steering, `${LEGACY_BLOCK}\n`); + + kiro.uninstall('global'); + expect(fs.existsSync(steering)).toBe(false); + }); + + it('kiro: uninstall removes our steering doc but leaves a sibling (product.md) untouched', () => { + const kiro = getTarget('kiro')!; + const sibling = path.join(tmpHome, '.kiro', 'steering', 'product.md'); + const ours = path.join(tmpHome, '.kiro', 'steering', 'codegraph.md'); + fs.mkdirSync(path.dirname(sibling), { recursive: true }); + fs.writeFileSync(sibling, '# Product\n\nMy team practices.\n'); + fs.writeFileSync(ours, `${LEGACY_BLOCK}\n`); + + kiro.uninstall('global'); + + expect(fs.existsSync(ours)).toBe(false); + expect(fs.existsSync(sibling)).toBe(true); + expect(fs.readFileSync(sibling, 'utf-8')).toContain('My team practices.'); + }); + + it('kiro: local install writes ./.kiro/settings/mcp.json and no steering doc (#529)', () => { + const kiro = getTarget('kiro')!; + const result = kiro.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.kiro/settings/mcp.json'))).toBe(true); + expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false); + }); + + it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => { + const antigravity = getTarget('antigravity')!; + antigravity.install('global', { autoAllow: true }); + + const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + expect(fs.existsSync(legacyFile)).toBe(true); + const cfg = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + // Crucially: does NOT touch the Gemini CLI's settings.json. + expect(fs.existsSync(path.join(tmpHome, '.gemini', 'settings.json'))).toBe(false); + }); + + it('antigravity: install writes to UNIFIED ~/.gemini/config/mcp_config.json when .migrated marker present', () => { + const antigravity = getTarget('antigravity')!; + // Plant the migration marker — same signal Antigravity itself drops + // when it migrates a user's config. + const unifiedDir = path.join(tmpHome, '.gemini', 'config'); + fs.mkdirSync(unifiedDir, { recursive: true }); + fs.writeFileSync(path.join(unifiedDir, '.migrated'), ''); + + antigravity.install('global', { autoAllow: true }); + + const unifiedFile = path.join(unifiedDir, 'mcp_config.json'); + expect(fs.existsSync(unifiedFile)).toBe(true); + const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + // Legacy path is NOT touched when the marker tells us migration happened. + expect(fs.existsSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'))).toBe(false); + }); + + it('antigravity: install writes to UNIFIED path when ~/.gemini/config/mcp_config.json already exists (even without marker)', () => { + const antigravity = getTarget('antigravity')!; + // Antigravity creates this file on first launch post-migration — its + // presence is the second signal we accept, in case the .migrated + // marker semantics change across Antigravity versions. + const unifiedFile = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); + fs.mkdirSync(path.dirname(unifiedFile), { recursive: true }); + fs.writeFileSync(unifiedFile, JSON.stringify({ mcpServers: {} }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + + const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + }); + + it('antigravity: entry has NO `type` field (Antigravity rejects entries with it)', () => { + const antigravity = getTarget('antigravity')!; + // Marker → unified path; doesn't matter which path, just inspect the entry shape. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); + + antigravity.install('global', { autoAllow: true }); + + const cfg = JSON.parse(fs.readFileSync( + path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8' + )); + expect(cfg.mcpServers.codegraph.type).toBeUndefined(); + expect(cfg.mcpServers.codegraph.command).toBeDefined(); + expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); + }); + + it('antigravity: install migrates a legacy codegraph entry to the unified path when marker appears', () => { + const antigravity = getTarget('antigravity')!; + // Simulate: user installed on the legacy path, then Antigravity + // migrated their config (dropped the `.migrated` marker + created + // the unified file). Re-running codegraph install should land + // codegraph in the new file AND strip the stale legacy entry. + const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + fs.mkdirSync(path.dirname(legacyFile), { recursive: true }); + fs.writeFileSync(legacyFile, JSON.stringify({ + mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, + }, null, 2) + '\n'); + fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); + + antigravity.install('global', { autoAllow: true }); + + const unified = JSON.parse(fs.readFileSync( + path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8' + )); + expect(unified.mcpServers.codegraph).toBeDefined(); + // Legacy file's codegraph entry got stripped. + const legacy = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); + expect(legacy.mcpServers).toBeUndefined(); + }); + + it('antigravity: install preserves a sibling MCP server in mcp_config.json (legacy path)', () => { + const antigravity = getTarget('antigravity')!; + const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); + fs.writeFileSync(mcpFile, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('antigravity: install preserves Antigravity-managed fields on sibling servers (e.g. disabled flag)', () => { + const antigravity = getTarget('antigravity')!; + // Antigravity adds `"disabled": true` to entries the user disables via + // the IDE. Install must not clobber that on sibling entries. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); + const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); + fs.writeFileSync(unified, JSON.stringify({ + mcpServers: { + 'code-review-graph': { + command: 'uvx', args: ['code-review-graph', 'serve'], disabled: true, + }, + }, + }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(unified, 'utf-8')); + expect(after.mcpServers['code-review-graph'].disabled).toBe(true); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('antigravity: uninstall removes only codegraph, sibling MCP server survives', () => { + const antigravity = getTarget('antigravity')!; + const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); + fs.writeFileSync(mcpFile, JSON.stringify({ + mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + antigravity.install('global', { autoAllow: true }); + antigravity.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('antigravity: uninstall sweeps BOTH legacy and unified paths (handles migration half-state)', () => { + const antigravity = getTarget('antigravity')!; + // User had codegraph in BOTH files (e.g. legacy install + post-migration + // re-install before our migration cleanup landed). Uninstall must clean + // both so a "fresh slate" really is fresh. + const legacy = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); + const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.mkdirSync(path.dirname(unified), { recursive: true }); + fs.writeFileSync(legacy, JSON.stringify({ + mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, + }, null, 2) + '\n'); + fs.writeFileSync(unified, JSON.stringify({ + mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, + }, null, 2) + '\n'); + fs.writeFileSync(path.join(path.dirname(unified), '.migrated'), ''); + + antigravity.uninstall('global'); + + const legacyAfter = JSON.parse(fs.readFileSync(legacy, 'utf-8')); + const unifiedAfter = JSON.parse(fs.readFileSync(unified, 'utf-8')); + expect(legacyAfter.mcpServers).toBeUndefined(); + expect(unifiedAfter.mcpServers).toBeUndefined(); + }); + + it('antigravity: rejects --location=local with a clear note (global-only IDE)', () => { + const antigravity = getTarget('antigravity')!; + expect(antigravity.supportsLocation('local')).toBe(false); + const result = antigravity.install('local', { autoAllow: true }); + expect(result.files).toEqual([]); + expect(result.notes?.join(' ')).toMatch(/no project-local config/); + }); + + it('antigravity: does not write GEMINI.md (only gemini target owns instructions)', () => { + const antigravity = getTarget('antigravity')!; + antigravity.install('global', { autoAllow: true }); + const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); + expect(fs.existsSync(geminiMd)).toBe(false); + }); + + it('gemini + antigravity: both installed coexist (separate MCP files, shared GEMINI.md)', () => { + const gemini = getTarget('gemini')!; + const antigravity = getTarget('antigravity')!; + gemini.install('global', { autoAllow: true }); + antigravity.install('global', { autoAllow: true }); + + const cliCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8')); + // Antigravity lands on the LEGACY path here since no .migrated marker + // was planted — same end-to-end check either way. + const ideCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'), 'utf-8')); + expect(cliCfg.mcpServers.codegraph).toBeDefined(); + expect(ideCfg.mcpServers.codegraph).toBeDefined(); + + // Uninstall one — the other's MCP entry must survive. + antigravity.uninstall('global'); + const cliAfter = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8')); + expect(cliAfter.mcpServers.codegraph).toBeDefined(); + }); + + it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + fs.writeFileSync(config, [ + 'model:', + ' default: qwen-3.7', + 'mcp_servers:', + ' other:', + ' command: other', + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' discord:', + ' - hermes-discord', + '', + ].join('\n')); + + const result = hermes.install('global', { autoAllow: true }); + expect(result.files[0].action).toBe('updated'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).toContain('model:\n default: qwen-3.7'); + expect(body).toContain('mcp_servers:\n other:\n command: other'); + expect(body).toContain(' codegraph:\n command: codegraph'); + expect(body).toContain(' - hermes-cli'); + expect(body).toContain(' - mcp-codegraph'); + expect(body).toContain(' discord:\n - hermes-discord'); + + const second = hermes.install('global', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + }); + + it('hermes: uninstall removes only codegraph MCP server and toolset entry', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + + hermes.install('global', { autoAllow: true }); + fs.appendFileSync(config, 'custom:\n keep: true\n'); + + hermes.uninstall('global'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).not.toContain('codegraph:'); + expect(body).not.toContain('mcp-codegraph'); + expect(body).toContain('custom:\n keep: true'); + }); + + // Regression for #456: PyYAML's default block style writes list items at the + // SAME indent as the parent key (`cli:` and its `- hermes-cli` are both at + // indent 2). The pre-fix line-based patcher mistook that first list item for + // the next sibling key, truncated the cli block, and spliced `- mcp-codegraph` + // at indent 4 BEFORE the existing items — producing unparseable YAML. + it('hermes: install preserves PyYAML-default list-at-same-indent style (issue #456)', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + const original = [ + 'model:', + ' default: gpt-4o', + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' - browser', + ' - clarify', + ' - terminal', + ' - web', + ' telegram:', + ' - hermes-telegram', + ' discord:', + ' - hermes-discord', + '', + ].join('\n'); + fs.writeFileSync(config, original); + + hermes.install('global', { autoAllow: true }); + const body = fs.readFileSync(config, 'utf-8'); + + // mcp-codegraph appended at the same 2-space indent as existing items + expect(body).toContain('\n - mcp-codegraph\n'); + // hermes-cli preserved + expect(body).toContain('\n - hermes-cli\n'); + // Sibling sections kept their indent — `telegram:` is still a key under + // platform_toolsets, not promoted up. + expect(body).toContain('\n telegram:\n - hermes-telegram\n'); + expect(body).toContain('\n discord:\n - hermes-discord\n'); + // No list items leaked to the platform_toolsets level (indent 0). + expect(body).not.toMatch(/^- browser/m); + expect(body).not.toMatch(/^- hermes-telegram/m); + + // The whole platform_toolsets block extracted by line search should + // start with `cli:` and not contain a stray 4-space `mcp-codegraph` + // appearing before the rest of the existing items. + expect(body).toContain(' cli:\n - hermes-cli\n - browser'); + + // Idempotent + const second = hermes.install('global', { autoAllow: true }); + expect(second.files[0]?.action).toBe('unchanged'); + }); + + it('hermes: uninstall reverses the install on a PyYAML-default config', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + const original = [ + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' - browser', + ' telegram:', + ' - hermes-telegram', + '', + ].join('\n'); + fs.writeFileSync(config, original); + + hermes.install('global', { autoAllow: true }); + const installed = fs.readFileSync(config, 'utf-8'); + expect(installed).toContain('- mcp-codegraph'); + expect(installed).toContain('codegraph:'); + + hermes.uninstall('global'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).not.toContain('mcp-codegraph'); + expect(body).not.toContain('command: codegraph'); + expect(body).toContain(' cli:\n - hermes-cli\n - browser'); + expect(body).toContain(' telegram:\n - hermes-telegram'); }); it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => { @@ -357,13 +880,40 @@ describe('Installer targets — partial-state idempotency', () => { const claude = getTarget('claude')!; const result = claude.install('local', { autoAllow: false }); // The MCP entry lands in ./.mcp.json — the file Claude Code reads. - expect(result.files.some((f) => f.path.endsWith('/.mcp.json'))).toBe(true); + expect(result.files.some((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'))).toBe(true); expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true); expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false); const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); expect(cfg.mcpServers.codegraph).toBeDefined(); }); + it('claude: install creates the CLAUDE.md codegraph block (#704)', () => { + const claude = getTarget('claude')!; + const result = claude.install('local', { autoAllow: false }); + const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); + expect(fs.existsSync(claudeMd)).toBe(true); + const body = fs.readFileSync(claudeMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('created'); + }); + + it('claude: install replaces a legacy CLAUDE.md codegraph block, keeping user content', () => { + const claude = getTarget('claude')!; + const claudeMd = path.join(tmpCwd, '.claude', 'CLAUDE.md'); + fs.mkdirSync(path.dirname(claudeMd), { recursive: true }); + fs.writeFileSync(claudeMd, `# My project rules\n\nUse tabs.\n\n${LEGACY_BLOCK}\n`); + + const result = claude.install('local', { autoAllow: false }); + + const body = fs.readFileSync(claudeMd, 'utf-8'); + expect(body).toContain('# My project rules'); + expect(body).toContain('Use tabs.'); + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + expect(result.files.find((f) => f.path.endsWith('CLAUDE.md'))?.action).toBe('updated'); + }); + it('claude: global install targets ~/.claude.json (user scope)', () => { const claude = getTarget('claude')!; claude.install('global', { autoAllow: false }); @@ -433,6 +983,120 @@ describe('Installer targets — partial-state idempotency', () => { expect(legacy.mcpServers.codegraph).toBeUndefined(); expect(legacy.mcpServers.other).toBeDefined(); }); + + // ---- Legacy auto-sync hook cleanup ---- + // Pre-0.8 installs wrote `codegraph mark-dirty` / `sync-if-dirty` + // hooks to settings.json. Both subcommands were removed from the CLI, + // so the Stop hook fails every turn ("unknown command + // 'sync-if-dirty'"). The installer must strip them on upgrade and + // uninstall — without touching the user's unrelated hooks. + + function seedSettings(loc: 'global' | 'local', settings: Record): string { + const dir = path.join(loc === 'global' ? tmpHome : tmpCwd, '.claude'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'settings.json'); + fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n'); + return file; + } + + // Realistic pre-0.8 settings.json: our two auto-sync hooks plus an + // unrelated GitKraken Stop hook the user added (matches the report). + function legacyHookSettings(): Record { + return { + hooks: { + PostToolUse: [ + { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'codegraph mark-dirty', async: true }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }, + { hooks: [{ type: 'command', command: '"/Users/me/gk" ai hook run --host claude-code' }] }, + ], + }, + }; + } + + it('claude: install strips stale codegraph auto-sync hooks but keeps the user\'s GitKraken hook', () => { + const claude = getTarget('claude')!; + const file = seedSettings('global', legacyHookSettings()); + + claude.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + // The only PostToolUse group held mark-dirty → the event is gone. + expect(after.hooks?.PostToolUse).toBeUndefined(); + const stopCommands = (after.hooks?.Stop ?? []).flatMap((g: any) => + (g.hooks ?? []).map((h: any) => h.command), + ); + expect(stopCommands).not.toContain('codegraph sync-if-dirty'); + // The unrelated GitKraken hook survives untouched. + expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true); + // Permissions still written as normal alongside the cleanup. + expect(after.permissions?.allow).toContain('mcp__codegraph__codegraph_search'); + }); + + it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => { + const file = seedSettings('global', { + hooks: { + Stop: [ + { + hooks: [ + { type: 'command', command: 'codegraph sync-if-dirty' }, + { type: 'command', command: 'gk ai hook run --host claude-code' }, + ], + }, + ], + }, + }); + + expect(cleanupLegacyHooks('global').action).toBe('removed'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(after.hooks.Stop[0].hooks.map((h: any) => h.command)).toEqual([ + 'gk ai hook run --host claude-code', + ]); + }); + + it('claude: cleanupLegacyHooks is a byte-for-byte no-op without codegraph hooks', () => { + const original = + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'gk ai hook run' }] }] } }, null, 2) + '\n'; + const file = seedSettings('global', JSON.parse(original)); + + expect(cleanupLegacyHooks('global').action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(original); + }); + + it('claude: cleanupLegacyHooks reports not-found when settings.json is absent', () => { + expect(cleanupLegacyHooks('global').action).toBe('not-found'); + }); + + it('claude: re-running install after a legacy cleanup leaves settings.json unchanged', () => { + const claude = getTarget('claude')!; + const file = seedSettings('global', legacyHookSettings()); + claude.install('global', { autoAllow: true }); + const firstPass = fs.readFileSync(file, 'utf-8'); + claude.install('global', { autoAllow: true }); + expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass); + }); + + it('claude: uninstall strips stale hooks written in the npx form (local)', () => { + const claude = getTarget('claude')!; + const file = seedSettings('local', { + hooks: { + PostToolUse: [ + { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph mark-dirty', async: true }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph sync-if-dirty' }] }, + ], + }, + }); + + claude.uninstall('local'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + // Both events emptied → the whole `hooks` object is removed. + expect(after.hooks).toBeUndefined(); + }); }); describe('Installer targets — registry', () => { @@ -441,6 +1105,10 @@ describe('Installer targets — registry', () => { expect(getTarget('cursor')?.id).toBe('cursor'); expect(getTarget('codex')?.id).toBe('codex'); expect(getTarget('opencode')?.id).toBe('opencode'); + expect(getTarget('hermes')?.id).toBe('hermes'); + expect(getTarget('gemini')?.id).toBe('gemini'); + expect(getTarget('antigravity')?.id).toBe('antigravity'); + expect(getTarget('kiro')?.id).toBe('kiro'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); @@ -548,6 +1216,179 @@ describe('Installer targets — TOML serializer (Codex backbone)', () => { }); }); +describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('un-home'); + tmpCwd = mkTmpDir('un-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('sweeps every agent it was installed on and reports removed for each (global)', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + + const reports = uninstallTargets(ALL_TARGETS, 'global'); + + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + expect(r.status).toBe('removed'); + expect(r.removedPaths.length).toBeGreaterThan(0); + // The actual config is gone afterward. + expect(t.detect('global').alreadyConfigured).toBe(false); + } + }); + + it('is safe on a clean slate — every agent reports not-configured, nothing removed', () => { + const reports = uninstallTargets(ALL_TARGETS, 'global'); + for (const r of reports) { + expect(r.status).toBe('not-configured'); + expect(r.removedPaths).toEqual([]); + } + }); + + it('reports removed only for agents that were actually configured', () => { + // Install on Claude only; the rest stay untouched. + getTarget('claude')!.install('global', { autoAllow: true }); + + const reports = uninstallTargets(ALL_TARGETS, 'global'); + + const claude = reports.find((r) => r.id === 'claude')!; + expect(claude.status).toBe('removed'); + expect(claude.displayName).toBe(getTarget('claude')!.displayName); + + for (const r of reports.filter((x) => x.id !== 'claude')) { + expect(r.status).toBe('not-configured'); + } + }); + + it('marks global-only agents as unsupported for a local sweep (and never touches them)', () => { + const reports = uninstallTargets(ALL_TARGETS, 'local'); + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + if (t.supportsLocation('local')) { + expect(r.status).toBe('not-configured'); + } else { + expect(r.status).toBe('unsupported'); + expect(r.removedPaths).toEqual([]); + expect(r.notes[0]).toMatch(/global-only/); + } + } + }); + + it('is idempotent — a second sweep finds nothing left to remove', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + const first = uninstallTargets(ALL_TARGETS, 'global'); + expect(first.some((r) => r.status === 'removed')).toBe(true); + + const second = uninstallTargets(ALL_TARGETS, 'global'); + for (const r of second) { + expect(r.status).toBe('not-configured'); + expect(r.removedPaths).toEqual([]); + } + }); + + it('a --target subset removes only the chosen agents, leaving siblings configured', () => { + getTarget('claude')!.install('global', { autoAllow: true }); + getTarget('cursor')!.install('global', { autoAllow: true }); + + const reports = uninstallTargets(resolveTargetFlag('claude', 'global'), 'global'); + + expect(reports.map((r) => r.id)).toEqual(['claude']); + expect(reports[0].status).toBe('removed'); + // Cursor was not in the subset — still configured. + expect(getTarget('cursor')!.detect('global').alreadyConfigured).toBe(true); + expect(getTarget('claude')!.detect('global').alreadyConfigured).toBe(false); + }); +}); + +describe('Installer — Cursor rules file cleanup on uninstall', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + const cursor = getTarget('cursor')!; + + beforeEach(() => { + tmpHome = mkTmpDir('cur-home'); + tmpCwd = mkTmpDir('cur-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const rulesFile = () => path.join(process.cwd(), '.cursor', 'rules', 'codegraph.mdc'); + + // The frontmatter a previous install wrote ahead of the marked block. + // `removeRulesEntry` recognizes it to decide whether the leftover .mdc + // is ours-to-delete or carries user content worth keeping. + const MDC_FRONTMATTER = [ + '---', + 'description: CodeGraph MCP usage guide — when to use which tool', + 'alwaysApply: true', + '---', + '', + ].join('\n'); + + function plantLegacyRulesFile(extra = ''): void { + fs.mkdirSync(path.dirname(rulesFile()), { recursive: true }); + fs.writeFileSync(rulesFile(), MDC_FRONTMATTER + LEGACY_BLOCK + '\n' + extra); + } + + it('uninstall deletes a leftover codegraph.mdc entirely (no orphaned frontmatter left behind)', () => { + plantLegacyRulesFile(); + expect(fs.existsSync(rulesFile())).toBe(true); + + cursor.uninstall('local'); + + // The whole file — frontmatter included — is gone, not just the block. + expect(fs.existsSync(rulesFile())).toBe(false); + }); + + it('install self-heals a leftover codegraph.mdc (#529)', () => { + plantLegacyRulesFile(); + const result = cursor.install('local', { autoAllow: true }); + expect(fs.existsSync(rulesFile())).toBe(false); + expect(result.files.some((f) => f.path.endsWith('codegraph.mdc') && f.action === 'removed')).toBe(true); + }); + + it('uninstall preserves user content added outside the codegraph markers (strips only our block)', () => { + plantLegacyRulesFile('## My own rule\nkeep me\n'); + + cursor.uninstall('local'); + + expect(fs.existsSync(rulesFile())).toBe(true); + const after = fs.readFileSync(rulesFile(), 'utf-8'); + expect(after).toContain('keep me'); + // Our tool-usage block is gone. + expect(after).not.toContain('codegraph_search'); + expect(after).not.toContain('CODEGRAPH_START'); + }); +}); + function listAllFiles(dir: string): string[] { if (!fs.existsSync(dir)) return []; const out: string[] = []; diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 728ed7c35..6f174f62d 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -3,7 +3,10 @@ * * Tests for installer config-writer fixes: * - readJsonFile error handling - * - writeClaudeMd section replacement + * + * (The CLAUDE.md instructions block is no longer written — see issue + * #529. The marker-based install/uninstall self-heal is covered in + * `installer-targets.test.ts`.) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -14,11 +17,6 @@ import * as os from 'os'; // We test the exported functions from config-writer import { writeMcpConfig, - writePermissions, - writeClaudeMd, - hasMcpConfig, - hasPermissions, - hasClaudeMdSection, } from '../src/installer/config-writer'; function createTempDir(): string { @@ -103,118 +101,4 @@ describe('Installer Config Writer', () => { expect(content.customField).toBe('preserved'); }); }); - - describe('writeClaudeMd section replacement', () => { - it('should create new CLAUDE.md with markers', () => { - const result = writeClaudeMd('local'); - - expect(result.created).toBe(true); - const content = fs.readFileSync(path.join(tempDir, '.claude', 'CLAUDE.md'), 'utf-8'); - expect(content).toContain(''); - expect(content).toContain(''); - expect(content).toContain('## CodeGraph'); - }); - - it('should replace marked section on update', () => { - // First write - writeClaudeMd('local'); - - // Modify file to add custom content before and after - const claudeMdPath = path.join(tempDir, '.claude', 'CLAUDE.md'); - const original = fs.readFileSync(claudeMdPath, 'utf-8'); - const modified = '## My Custom Section\n\nCustom content\n\n' + original + '\n\n## Another Section\n\nMore content\n'; - fs.writeFileSync(claudeMdPath, modified); - - // Second write should leave the marked block as-is (byte-identical - // body, so result is `created:false, updated:false` — both flags - // are off but the surrounding custom content must survive). - writeClaudeMd('local'); - - const final = fs.readFileSync(claudeMdPath, 'utf-8'); - expect(final).toContain('## My Custom Section'); - expect(final).toContain('Custom content'); - expect(final).toContain('## Another Section'); - expect(final).toContain('More content'); - expect(final).toContain('## CodeGraph'); - }); - - it('should use atomic writes (no temp files left behind)', () => { - writeClaudeMd('local'); - - const claudeDir = path.join(tempDir, '.claude'); - const files = fs.readdirSync(claudeDir); - const tmpFiles = files.filter(f => f.includes('.tmp.')); - expect(tmpFiles).toHaveLength(0); - }); - - it('should not overwrite content after unmarked section with ### subsections', () => { - // Create a CLAUDE.md with an unmarked CodeGraph section that has ### subsections - // followed by another ## section - const claudeDir = path.join(tempDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); - fs.writeFileSync(claudeMdPath, [ - '## Pre-existing Section', - '', - 'Some content', - '', - '## CodeGraph', - '', - '### Subsection A', - '', - 'Old codegraph content', - '', - '### Subsection B', - '', - 'More old content', - '', - '## Important Section After', - '', - 'This content must not be overwritten!', - '', - ].join('\n')); - - const result = writeClaudeMd('local'); - expect(result.updated).toBe(true); - - const final = fs.readFileSync(claudeMdPath, 'utf-8'); - // The section after CodeGraph must be preserved - expect(final).toContain('## Important Section After'); - expect(final).toContain('This content must not be overwritten!'); - // Pre-existing section should also be preserved - expect(final).toContain('## Pre-existing Section'); - // New CodeGraph content should be present with markers - expect(final).toContain(''); - expect(final).toContain(''); - }); - - it('should replace unmarked section without subsections', () => { - const claudeDir = path.join(tempDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); - // Note: regex needs \n before ## CodeGraph, so prefix with another section - fs.writeFileSync(claudeMdPath, [ - '## Intro', - '', - 'Preamble', - '', - '## CodeGraph', - '', - 'Old simple content', - '', - '## Next Section', - '', - 'Must be preserved', - '', - ].join('\n')); - - writeClaudeMd('local'); - - const final = fs.readFileSync(claudeMdPath, 'utf-8'); - expect(final).toContain(''); - expect(final).toContain('## Next Section'); - expect(final).toContain('Must be preserved'); - expect(final).not.toContain('Old simple content'); - }); - }); }); diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts new file mode 100644 index 000000000..5b551c136 --- /dev/null +++ b/__tests__/integration/full-pipeline.test.ts @@ -0,0 +1,272 @@ +/** + * End-to-end pipeline integration tests + * + * Exercises the full happy path that unit tests cover in isolation: + * init → indexAll → resolveReferences → searchNodes/getCallers/buildContext → sync + * + * Also covers two error paths that were previously uncovered: + * - Indexing a file that contains a syntactically invalid snippet + * (parse errors must not abort the batch). + * - Sync correctly applies adds + modifies + removes in a single pass. + * + * A synthetic ~120-file project is generated per test (5k files would + * dwarf the test runner; 120 files of varied TS shape is enough to + * stress the resolver and graph layers without slowing the suite to a + * crawl). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../../src/index'; + +function createTempDir(prefix = 'codegraph-int-'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanupTempDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Generate a synthetic TypeScript project with the given module count. + * Each module exports a function that calls the previous module's + * function so that the resolver has real import edges + call edges to + * resolve. The first module is a leaf; the last is the root. + */ +function generateSyntheticProject(root: string, moduleCount: number): void { + const srcDir = path.join(root, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Leaf module — no imports. + fs.writeFileSync( + path.join(srcDir, `mod0.ts`), + `export function fn0(x: number): number { return x + 1; }\n` + + `export class Mod0 { ping(): string { return 'mod0'; } }\n` + ); + + for (let i = 1; i < moduleCount; i++) { + const prev = i - 1; + fs.writeFileSync( + path.join(srcDir, `mod${i}.ts`), + `import { fn${prev}, Mod${prev} } from './mod${prev}';\n` + + `export function fn${i}(x: number): number { return fn${prev}(x) + 1; }\n` + + `export class Mod${i} extends Mod${prev} {\n` + + ` call${i}(): number { return fn${i}(${i}); }\n` + + `}\n` + ); + } + + // Entry point file. + fs.writeFileSync( + path.join(srcDir, 'index.ts'), + `import { fn${moduleCount - 1}, Mod${moduleCount - 1} } from './mod${moduleCount - 1}';\n` + + `export function entry(): number {\n` + + ` const m = new Mod${moduleCount - 1}();\n` + + ` return fn${moduleCount - 1}(0) + m.call${moduleCount - 1}();\n` + + `}\n` + ); +} + +describe('Integration: full pipeline', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('runs init → index → resolve → search → callers → context → sync', async () => { + const MODULE_COUNT = 120; + generateSyntheticProject(tempDir, MODULE_COUNT); + + // ── init ────────────────────────────────────────────────────── + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + // ── indexAll ──────────────────────────────────────────────── + const indexResult = await cg.indexAll(); + // Synthetic project: MODULE_COUNT mod files + 1 index file. + expect(indexResult.filesIndexed).toBeGreaterThanOrEqual(MODULE_COUNT); + + const statsAfterIndex = cg.getStats(); + expect(statsAfterIndex.fileCount).toBeGreaterThanOrEqual(MODULE_COUNT); + expect(statsAfterIndex.nodeCount).toBeGreaterThan(MODULE_COUNT * 2); + + // ── resolveReferences ──────────────────────────────────────── + // Many call-site edges are wired up during extraction itself, so + // the unresolved-reference queue may already be drained by the + // time we get here. We assert that resolve completes cleanly and + // returns a well-formed result; downstream callers/callees + // assertions verify the graph is actually populated. + cg.reinitializeResolver(); + const resolution = cg.resolveReferences(); + expect(resolution).toBeDefined(); + expect(resolution.stats).toBeDefined(); + expect(typeof resolution.stats.total).toBe('number'); + expect(typeof resolution.stats.resolved).toBe('number'); + + // ── searchNodes ────────────────────────────────────────────── + const entryResults = cg.searchNodes('entry', { limit: 10 }); + expect(entryResults.length).toBeGreaterThan(0); + const entryNode = entryResults.find((r) => r.node.name === 'entry'); + expect(entryNode).toBeDefined(); + + const midResults = cg.searchNodes(`fn50`, { limit: 10 }); + expect(midResults.find((r) => r.node.name === 'fn50')).toBeDefined(); + + // ── getCallers / getCallees ────────────────────────────────── + const fn0Results = cg.searchNodes('fn0', { limit: 5 }); + const fn0Node = fn0Results.find((r) => r.node.name === 'fn0'); + expect(fn0Node).toBeDefined(); + const callers = cg.getCallers(fn0Node!.node.id); + // fn0 is called by fn1 (at least). After resolution this should + // be wired up. + expect(Array.isArray(callers)).toBe(true); + + // ── buildContext ───────────────────────────────────────────── + const context = await cg.buildContext('entry function chain', { + maxNodes: 10, + format: 'markdown', + }); + expect(typeof context).toBe('string'); + expect((context as string).length).toBeGreaterThan(0); + + // ── sync (add + modify + remove in one pass) ───────────────── + // Add: a new file referencing entry(). + fs.writeFileSync( + path.join(tempDir, 'src', 'consumer.ts'), + `import { entry } from './index';\nexport const result = entry();\n` + ); + // Modify: change mod0. + fs.writeFileSync( + path.join(tempDir, 'src', 'mod0.ts'), + `export function fn0(x: number): number { return x + 2; }\n` + + `export function newHelper(): string { return 'new'; }\n` + + `export class Mod0 { ping(): string { return 'mod0v2'; } }\n` + ); + // Remove: drop mod1 — note this will leave dangling imports in + // mod2, which the resolver should tolerate. + fs.unlinkSync(path.join(tempDir, 'src', 'mod1.ts')); + + const syncResult = await cg.sync(); + expect(syncResult.filesAdded).toBeGreaterThanOrEqual(1); + expect(syncResult.filesModified).toBeGreaterThanOrEqual(1); + expect(syncResult.filesRemoved).toBeGreaterThanOrEqual(1); + + // New symbol must now be findable; removed file's symbols gone. + expect(cg.searchNodes('newHelper').length).toBeGreaterThan(0); + + // Removed file should no longer appear in the indexed file list. + // (FTS prefix matching makes name-based assertions unreliable here — + // Mod10/Mod11/… all start with "Mod1" — so we check the file set + // instead.) + const filesAfterSync = cg.getNodesInFile('src/mod1.ts'); + expect(filesAfterSync).toHaveLength(0); + } finally { + cg.destroy(); + } + }, 60_000); + + it('keeps indexing files when one file has a parse error', async () => { + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Valid files + fs.writeFileSync( + path.join(srcDir, 'good1.ts'), + `export function good1(): number { return 1; }\n` + ); + fs.writeFileSync( + path.join(srcDir, 'good2.ts'), + `export function good2(): number { return 2; }\n` + ); + // Intentionally broken file — unclosed brace, stray tokens. + fs.writeFileSync( + path.join(srcDir, 'broken.ts'), + `export function broken(\n this is { not valid typescript at all\n` + ); + + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + const result = await cg.indexAll(); + // The two good files must still be indexed regardless of the + // broken one. Tree-sitter is error-tolerant so it may still + // extract a partial AST from broken.ts — but the test only + // requires that the batch completes and finds the good symbols. + expect(result.filesIndexed).toBeGreaterThanOrEqual(2); + + const good1 = cg.searchNodes('good1'); + const good2 = cg.searchNodes('good2'); + expect(good1.find((r) => r.node.name === 'good1')).toBeDefined(); + expect(good2.find((r) => r.node.name === 'good2')).toBeDefined(); + } finally { + cg.destroy(); + } + }, 30_000); + + it('handles repeated sync calls when nothing has changed', async () => { + generateSyntheticProject(tempDir, 10); + + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + await cg.indexAll(); + const statsBefore = cg.getStats(); + + const first = await cg.sync(); + const second = await cg.sync(); + + // Subsequent sync with no changes should be a no-op. + expect(first.filesAdded + first.filesModified + first.filesRemoved).toBe(0); + expect(second.filesAdded + second.filesModified + second.filesRemoved).toBe(0); + + const statsAfter = cg.getStats(); + expect(statsAfter.fileCount).toBe(statsBefore.fileCount); + expect(statsAfter.nodeCount).toBe(statsBefore.nodeCount); + } finally { + cg.destroy(); + } + }, 30_000); + + it('reports edgesCreated including resolution + synthesizer phases', async () => { + // The synthetic project has cross-file imports, calls, and extends — + // all wired up in the resolution phase, AFTER the orchestrator's + // per-file extraction counter is done. The CLI summary used to read + // only the extraction-phase counter and undercount the graph; this + // test pins the counter to the true DB totals across all phases. + generateSyntheticProject(tempDir, 30); + + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + + try { + const result = await cg.indexAll(); + const stats = cg.getStats(); + + expect(result.success).toBe(true); + expect(result.nodesCreated).toBe(stats.nodeCount); + expect(result.edgesCreated).toBe(stats.edgeCount); + // Sanity: cross-file resolution had something to do — calls/extends + // edges should exist beyond the bare extraction-time contains edges. + const containsOnly = stats.edgesByKind.contains ?? 0; + expect(stats.edgeCount).toBeGreaterThan(containsOnly); + } finally { + cg.destroy(); + } + }, 30_000); +}); diff --git a/__tests__/integration/lru-cache.test.ts b/__tests__/integration/lru-cache.test.ts new file mode 100644 index 000000000..8156760ae --- /dev/null +++ b/__tests__/integration/lru-cache.test.ts @@ -0,0 +1,96 @@ +/** + * LRUCache unit tests + * + * Covers the eviction guarantees that the resolver relies on: + * - capacity is enforced (never exceeds max) + * - LRU ordering: hot keys survive eviction passes + * - has()/get()/set()/clear() behave like the original Map shape + * - null values are storable (the fileCache uses null for "failed read") + */ + +import { describe, it, expect } from 'vitest'; +import { LRUCache } from '../../src/resolution/lru-cache'; + +describe('LRUCache', () => { + it('enforces capacity by evicting the oldest entry on overflow', () => { + const cache = new LRUCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.set('d', 4); // evicts 'a' + + expect(cache.size).toBe(3); + expect(cache.has('a')).toBe(false); + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + expect(cache.get('d')).toBe(4); + }); + + it('promotes touched keys to most-recent so they survive eviction', () => { + const cache = new LRUCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + + // Touch 'a' — it should now be most-recent. + expect(cache.get('a')).toBe(1); + + cache.set('d', 4); // evicts the LRU, which is now 'b' (not 'a') + + expect(cache.has('a')).toBe(true); + expect(cache.has('b')).toBe(false); + expect(cache.has('c')).toBe(true); + expect(cache.has('d')).toBe(true); + }); + + it('overwriting an existing key refreshes its recency but does not grow size', () => { + const cache = new LRUCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.set('a', 99); // 'a' is now most-recent + + expect(cache.size).toBe(2); + expect(cache.get('a')).toBe(99); + + cache.set('c', 3); // should evict 'b', not 'a' + + expect(cache.has('a')).toBe(true); + expect(cache.has('b')).toBe(false); + expect(cache.has('c')).toBe(true); + }); + + it('stores null values (used by the file content cache)', () => { + const cache = new LRUCache(2); + cache.set('missing.ts', null); + expect(cache.has('missing.ts')).toBe(true); + expect(cache.get('missing.ts')).toBeNull(); + }); + + it('clear() resets the cache', () => { + const cache = new LRUCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.has('a')).toBe(false); + }); + + it('rejects non-positive capacity', () => { + expect(() => new LRUCache(0)).toThrow(); + expect(() => new LRUCache(-1)).toThrow(); + expect(() => new LRUCache(NaN)).toThrow(); + }); + + it('stays bounded under heavy churn (regression for OOM scenario)', () => { + const cache = new LRUCache(100); + for (let i = 0; i < 10_000; i++) { + cache.set(`key${i}`, i); + } + expect(cache.size).toBe(100); + // The last 100 keys should still be present, the rest evicted. + expect(cache.has('key9999')).toBe(true); + expect(cache.has('key9900')).toBe(true); + expect(cache.has('key0')).toBe(false); + }); +}); diff --git a/__tests__/integration/mcp-input-limits.test.ts b/__tests__/integration/mcp-input-limits.test.ts new file mode 100644 index 000000000..7471f8289 --- /dev/null +++ b/__tests__/integration/mcp-input-limits.test.ts @@ -0,0 +1,109 @@ +/** + * MCP tool input-size limits + * + * Regression coverage for the DoS vector: MCP clients can ship + * unbounded payloads (`query`, `task`, `symbol`, `projectPath`, + * `path`, `pattern`). Before the cap, a 100MB string would hit + * the FTS5 layer and pin the server. These tests assert that the + * tool layer rejects oversize inputs early. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../../src/index'; +import { ToolHandler } from '../../src/mcp/tools'; + +describe('MCP input size limits', () => { + let tempDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-limits-')); + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src', 'a.ts'), + `export function alpha(): number { return 1; }\n` + ); + cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.destroy(); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('accepts a normal-sized query', async () => { + const result = await handler.execute('codegraph_search', { query: 'alpha' }); + expect(result.isError).toBeFalsy(); + }); + + it('rejects an oversize query on codegraph_search', async () => { + const huge = 'a'.repeat(20_000); + const result = await handler.execute('codegraph_search', { query: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize query on codegraph_explore', async () => { + const huge = 'b'.repeat(50_000); + const result = await handler.execute('codegraph_explore', { query: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize symbol on codegraph_callers', async () => { + const huge = 'c'.repeat(15_000); + const result = await handler.execute('codegraph_callers', { symbol: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize symbol on codegraph_impact', async () => { + const huge = 'd'.repeat(11_000); + const result = await handler.execute('codegraph_impact', { symbol: huge }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/maximum length/i); + }); + + it('rejects an oversize projectPath', async () => { + const hugePath = '/tmp/' + 'x'.repeat(5_000); + const result = await handler.execute('codegraph_search', { + query: 'alpha', + projectPath: hugePath, + }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/projectPath/); + }); + + it('rejects an oversize path filter on codegraph_files', async () => { + const hugePath = 'src/' + 'y'.repeat(5_000); + const result = await handler.execute('codegraph_files', { path: hugePath }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/path/); + }); + + it('rejects an oversize glob pattern on codegraph_files', async () => { + const hugePattern = '*'.repeat(5_000); + const result = await handler.execute('codegraph_files', { pattern: hugePattern }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/pattern/); + }); + + it('rejects a non-string projectPath', async () => { + const result = await handler.execute('codegraph_search', { + query: 'alpha', + projectPath: 12345 as unknown as string, + }); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toMatch(/projectPath/); + }); +}); diff --git a/__tests__/iterate-nodes-by-kind.test.ts b/__tests__/iterate-nodes-by-kind.test.ts new file mode 100644 index 000000000..19ee05f67 --- /dev/null +++ b/__tests__/iterate-nodes-by-kind.test.ts @@ -0,0 +1,62 @@ +/** + * `QueryBuilder.iterateNodesByKind` — the streaming scan that fixes the #610 + * OOM. The dynamic-edge synthesizers used to `getNodesByKind('function')` / + * `('method')`, materializing every symbol into one array (gigabytes on a + * symbol-dense project → JS-heap OOM). They now iterate. These tests pin the + * two properties that refactor relies on: the streamed set equals the eager + * set, and an open iterator cursor coexists with other queries on the same + * connection. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +describe('iterateNodesByKind (#610 streaming)', () => { + let dir: string; + let cg: CodeGraph; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-iter-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'a.ts'), + 'export function foo() { return 1; }\n' + + 'export function bar() { return 2; }\n' + + 'export class C { m() { return 3; } n() { return 4; } }\n' + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + }); + + afterEach(() => { + try { cg.close(); } catch { /* ignore */ } + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('yields exactly the same nodes as the eager getNodesByKind', () => { + const q = (cg as unknown as { queries: any }).queries; + for (const kind of ['function', 'method', 'class'] as const) { + const eager = q.getNodesByKind(kind).map((n: any) => n.id).sort(); + const streamed = [...q.iterateNodesByKind(kind)].map((n: any) => n.id).sort(); + expect(streamed).toEqual(eager); + } + // sanity: the fixture actually produced functions + methods to stream + expect([...q.iterateNodesByKind('function')].length).toBeGreaterThan(0); + expect([...q.iterateNodesByKind('method')].length).toBeGreaterThan(0); + }); + + it('keeps the cursor valid while other queries run mid-iteration', () => { + const q = (cg as unknown as { queries: any }).queries; + let seen = 0; + for (const n of q.iterateNodesByKind('function')) { + // A different prepared statement stepped on the same connection while the + // iterator's cursor is open must not corrupt it. + const again = q.getNodeById(n.id); + expect(again?.id).toBe(n.id); + seen++; + } + expect(seen).toBe(q.getNodesByKind('function').length); + }); +}); diff --git a/__tests__/mcp-catchup-gate.test.ts b/__tests__/mcp-catchup-gate.test.ts new file mode 100644 index 000000000..6baee07c4 --- /dev/null +++ b/__tests__/mcp-catchup-gate.test.ts @@ -0,0 +1,122 @@ +/** + * MCP catch-up gate — first tool call blocks on the engine's post-open + * filesystem reconcile so it never serves rows for files that were + * deleted (or edited) while no MCP server was running. + * + * Background: `MCPEngine.catchUpSync()` fires `cg.sync()` in the background. + * Before this fix it was fire-and-forget — a tool call could race past it + * and return rows for files that no longer exist on disk. The per-file + * staleness banner (`withStalenessNotice`) couldn't help, because + * `getPendingFiles()` is populated by the watcher, not by catch-up. + * + * The fix: `catchUpSync()` pushes its promise into the `ToolHandler` via + * `setCatchUpGate(p)`; the first `execute()` call awaits the gate and then + * clears it. These tests exercise the gate directly (deterministic) and + * the engine-driven path (proves the engine actually pokes the gate). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('MCP catch-up gate', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-catchup-gate-')); + fs.mkdirSync(path.join(testDir, 'src')); + fs.writeFileSync( + path.join(testDir, 'src', 'survivor.ts'), + 'export function survivor() { return 1; }\n', + ); + fs.writeFileSync( + path.join(testDir, 'src', 'deleted-later.ts'), + 'export function deletedLater() { return 2; }\n', + ); + + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + try { cg.unwatch(); } catch { /* ignore */ } + try { cg.close(); } catch { /* ignore */ } + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('awaits the gate before serving the first tool call', async () => { + let gateResolved = false; + const gate = new Promise((resolve) => { + setTimeout(() => { gateResolved = true; resolve(); }, 80); + }); + handler.setCatchUpGate(gate); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + expect(gateResolved).toBe(true); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/survivor/); + }); + + it('drops the gate after first await — second call does not re-wait', async () => { + let awaitCount = 0; + const gate = new Promise((resolve) => { + awaitCount++; + setTimeout(resolve, 20); + }); + handler.setCatchUpGate(gate); + + await handler.execute('codegraph_search', { query: 'survivor' }); + const before = awaitCount; + await handler.execute('codegraph_search', { query: 'survivor' }); + // The promise body runs once when constructed; second execute never + // resubscribes to a fresh promise because the gate field was nulled. + expect(awaitCount).toBe(before); + }); + + it('catch-up reconciles a deleted file before the first tool call sees it', async () => { + // Simulate the empty-project / deleted-files startup case: file is in + // the DB (we indexed it above) but vanishes from disk before the MCP + // server's first query. The catch-up sync, awaited via the gate, + // must remove the row so the first tool call returns no hit. + fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts')); + + // Push the actual catch-up sync as the gate — same flow the MCP engine + // uses (`cg.sync()` returns a Promise, the wrapper voids it). + handler.setCatchUpGate(cg.sync().then(() => undefined)); + + const res = await handler.execute('codegraph_search', { query: 'deletedLater' }); + expect(res.isError).toBeFalsy(); + const text = res.content[0].text; + expect(text).not.toMatch(/src\/deleted-later\.ts/); + }); + + it('catch-up that converges the project to 0 files clears all rows', async () => { + // Worst case: every source file is gone between sessions. Without the + // gate, the first tool call serves whatever was in the DB. With the + // gate + the orchestrator's filesystem reconcile, the DB drains. + fs.unlinkSync(path.join(testDir, 'src', 'survivor.ts')); + fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts')); + + handler.setCatchUpGate(cg.sync().then(() => undefined)); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + expect(res.isError).toBeFalsy(); + expect(cg.getStats().fileCount).toBe(0); + }); + + it('gate that rejects does not break the tool call', async () => { + // A catch-up sync failure (lock contention, transient FS error) must + // not poison tool dispatch — the engine logs it, the handler proceeds. + handler.setCatchUpGate(Promise.reject(new Error('simulated sync failure'))); + + const res = await handler.execute('codegraph_search', { query: 'survivor' }); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/survivor/); + }); +}); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts new file mode 100644 index 000000000..c00d528f6 --- /dev/null +++ b/__tests__/mcp-daemon.test.ts @@ -0,0 +1,435 @@ +/** + * Shared MCP daemon — issue #411. + * + * Validates the daemon architecture in `src/mcp/{daemon,proxy,session,index}.ts` + * AFTER the review fixes: + * + * - The daemon is a *detached* background process; every `serve --mcp` + * invocation is a thin proxy to it. Two invocations against one project + * share ONE daemon. + * - Concurrent launchers converge on a single daemon (the must-fix-1 + * lockfile-race: an empty-pidfile window used to let a racing candidate + * delete the winner's lock → two daemons). + * - Killing the launcher that spawned the daemon does NOT take the daemon + * down — other attached clients keep working (the must-fix-2 detach: the + * in-process daemon used to die with its launcher's process group and + * orphan on host SIGKILL, regressing #277). + * - A stale lockfile (dead pid) is cleared; `CODEGRAPH_NO_DAEMON=1` opts out; + * the proxy refuses to attach across a version mismatch; the daemon + * idle-times-out after the last client leaves (so a single session can't + * leak a daemon forever). + * + * These tests intentionally spawn real `node dist/bin/codegraph.js` processes + * over real sockets/pipes — the same surface a Claude Code / Cursor / Codex + * install exercises. The daemon logs to `.codegraph/daemon.log` (it has no + * client stderr of its own), so daemon-side assertions read that file. + * + * `realRoot` vs `tempDir`: processes are spawned with the (possibly symlinked) + * `tempDir` as cwd/rootUri — on macOS `os.tmpdir()` lives under `/var`, a + * symlink to `/private/var`, and a spawned child's `process.cwd()` is already + * realpath'd. The daemon canonicalizes the root with `realpathSync`, so all + * path assertions use `realRoot` (the canonical form). That this matches end to + * end is itself the proof the canonicalization works. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { getDaemonSocketPath } from '../src/mcp/daemon-paths'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +interface SpawnedServer { + child: ChildProcessWithoutNullStreams; + stdout: string[]; + stderr: string[]; +} + +function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer { + const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // #618: the daemon-attach log line is now off by default; opt the test + // harness into it (CODEGRAPH_MCP_LOG_ATTACH=1) so the attach assertions + // below can still observe a successful attach. A per-test env still wins. + env: { CODEGRAPH_MCP_LOG_ATTACH: '1', ...process.env, ...env }, + }) as ChildProcessWithoutNullStreams; + // Swallow spawn/EPIPE errors so killing a child mid-write can't surface as an + // unhandled error that crashes the vitest worker. + child.on('error', () => { /* ignore */ }); + child.stdin.on('error', () => { /* ignore */ }); + const stdout: string[] = []; + const stderr: string[] = []; + let stdoutBuf = ''; + let stderrBuf = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuf += chunk.toString('utf8'); + let idx: number; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + stdout.push(stdoutBuf.slice(0, idx)); + stdoutBuf = stdoutBuf.slice(idx + 1); + } + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf8'); + let idx: number; + while ((idx = stderrBuf.indexOf('\n')) !== -1) { + stderr.push(stderrBuf.slice(0, idx)); + stderrBuf = stderrBuf.slice(idx + 1); + } + }); + return { child, stdout, stderr }; +} + +function sendMessage(child: ChildProcessWithoutNullStreams, msg: unknown): void { + try { child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* child may be gone */ } +} + +function sendInitialize(child: ChildProcessWithoutNullStreams, rootUri: string, id: number): void { + sendMessage(child, { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri, + }, + }); +} + +/** Find a JSON-RPC response with the given id (result OR error) on stdout. */ +function findResponse(stdout: string[], id: number): any | null { + for (const line of stdout) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed && parsed.id === id && (parsed.result !== undefined || parsed.error !== undefined)) { + return parsed; + } + } catch { /* not JSON */ } + } + return null; +} + +function waitFor( + predicate: () => T | undefined | null | false, + timeoutMs: number, + pollMs = 25, +): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + let v: T | undefined | null | false; + try { v = predicate(); } catch (e) { return reject(e); } + if (v) return resolve(v as T); + if (Date.now() - started > timeoutMs) return reject(new Error(`Timed out after ${timeoutMs}ms`)); + setTimeout(tick, pollMs); + }; + tick(); + }); +} + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function readLockPid(root: string): number | null { + try { + const raw = fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8'); + const info = JSON.parse(raw); + return typeof info.pid === 'number' ? info.pid : null; + } catch { return null; } +} + +function readDaemonLog(root: string): string { + try { return fs.readFileSync(path.join(root, '.codegraph', 'daemon.log'), 'utf8'); } + catch { return ''; } +} + +function countListeningLines(root: string): number { + return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length; +} + +function killTree(...procs: ChildProcessWithoutNullStreams[]): void { + for (const p of procs) { + if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } } + } +} + +async function waitProcessExit(pid: number, timeoutMs: number): Promise { + return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false); +} + +describe('Shared MCP daemon (issue #411)', () => { + let tempDir: string; // the (possibly symlinked) path processes are spawned with + let realRoot: string; // its canonical form — what the daemon keys paths on + const servers: SpawnedServer[] = []; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-daemon-')); + const cg = await CodeGraph.init(tempDir); + cg.close(); + realRoot = fs.realpathSync(tempDir); + }); + + afterEach(async () => { + killTree(...servers.map((s) => s.child)); + // The daemon is detached (not a tracked child) — reap it explicitly via the + // pid it recorded, so a test can't leak a background daemon. Guard against + // our own pid: the version-mismatch test plants `pid: process.pid` in the + // lockfile, and we must never SIGKILL the vitest worker. + const daemonPid = readLockPid(realRoot); + if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { + try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + } + await new Promise((r) => setTimeout(r, 50)); + servers.length = 0; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('two invocations share ONE detached daemon; both attach as proxies', async () => { + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + + const first = spawnServer(tempDir, env); + servers.push(first); + sendInitialize(first.child, `file://${tempDir}`, 1); + const firstResp = await waitFor(() => findResponse(first.stdout, 1), 10000); + expect(firstResp.result.serverInfo.name).toBe('codegraph'); + + // The launcher is a PROXY (not the daemon itself) — that's the detach fix. + await waitFor(() => first.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + + // A detached daemon came up and recorded itself. + await waitFor(() => fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid')), 8000); + await waitFor(() => countListeningLines(realRoot) >= 1, 8000); + const daemonPid = readLockPid(realRoot); + expect(daemonPid).toBeTruthy(); + expect(isAlive(daemonPid!)).toBe(true); + // The socket exists at the path the code computes from the canonical root. + // On Windows the daemon listens on a named pipe (\\.\pipe\...), which isn't + // a filesystem entry — existsSync doesn't apply there, and the "Attached to + // shared daemon" proof above already confirms the proxy reached it. + if (process.platform !== 'win32') { + expect(fs.existsSync(getDaemonSocketPath(realRoot))).toBe(true); + } + + // Second invocation attaches as a proxy to the SAME daemon. + const second = spawnServer(tempDir, env); + servers.push(second); + sendInitialize(second.child, `file://${tempDir}`, 2); + const secondResp = await waitFor(() => findResponse(second.stdout, 2), 10000); + expect(secondResp.result.serverInfo.name).toBe('codegraph'); + await waitFor(() => second.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + + // Exactly one daemon ever bound, and it's the same pid both attached to. + expect(countListeningLines(realRoot)).toBe(1); + expect(readLockPid(realRoot)).toBe(daemonPid); + }, 40000); + + it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => { + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + + // Fire three launchers as close to simultaneously as possible — this is the + // race window where the old code could end up with two daemons. + const procs = [spawnServer(tempDir, env), spawnServer(tempDir, env), spawnServer(tempDir, env)]; + procs.forEach((p, i) => { servers.push(p); sendInitialize(p.child, `file://${tempDir}`, i + 1); }); + + // All three get a valid initialize response... + for (let i = 0; i < procs.length; i++) { + const resp = await waitFor(() => findResponse(procs[i].stdout, i + 1), 12000); + expect(resp.result.serverInfo.name).toBe('codegraph'); + } + // ...and all three attached as proxies (none fell back / wedged). + for (const p of procs) { + await waitFor(() => p.stderr.some((l) => l.includes('Attached to shared daemon')), 10000); + } + + // The decisive assertion: exactly ONE daemon bound the socket. Losing + // candidates log "already holds the lock; exiting" and never listen. + expect(countListeningLines(realRoot)).toBe(1); + const daemonPid = readLockPid(realRoot); + expect(daemonPid).toBeTruthy(); + expect(isAlive(daemonPid!)).toBe(true); + }, 45000); + + it('daemon survives the first client dying; a second client keeps working (must-fix 2 / #277)', async () => { + // Idle high so the daemon doesn't reap mid-test; poll fast so proxy 1 + // notices its dead parent quickly. + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '200' }; + + const first = spawnServer(tempDir, env); + servers.push(first); + sendInitialize(first.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(first.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + expect(isAlive(daemonPid)).toBe(true); + + const second = spawnServer(tempDir, env); + servers.push(second); + sendInitialize(second.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(second.stdout, 1), 10000); + await waitFor(() => second.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + + // Kill the launcher that spawned the daemon. With the old in-process design + // this would take the daemon (and thus the second client) down. + killTree(first.child); + + // The daemon is detached — it must still be alive a beat later. + await new Promise((r) => setTimeout(r, 1500)); + expect(isAlive(daemonPid)).toBe(true); + + // And the second client can still drive a real tool call through it. + sendMessage(second.child, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const toolsResp = await waitFor(() => findResponse(second.stdout, 2), 10000); + expect(Array.isArray(toolsResp.result.tools)).toBe(true); + expect(toolsResp.result.tools.length).toBeGreaterThan(0); + }, 45000); + + it('CODEGRAPH_NO_DAEMON=1 keeps each process independent (no socket/pidfile)', async () => { + const env = { CODEGRAPH_NO_DAEMON: '1' }; + const first = spawnServer(tempDir, env); + servers.push(first); + sendInitialize(first.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(first.stdout, 1), 10000); + // Direct mode — no daemon machinery touched. + expect(first.stderr.some((l) => l.includes('Attached to shared daemon'))).toBe(false); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.log'))).toBe(false); + }, 20000); + + it('clears a stale (dead-pid) lockfile and a fresh daemon takes over', async () => { + // Plant a lockfile pointing at a definitely-dead pid + the real socket path. + fs.writeFileSync( + path.join(realRoot, '.codegraph', 'daemon.pid'), + JSON.stringify({ + pid: 999_999, + version: '0.0.0-fake', + socketPath: getDaemonSocketPath(realRoot), + startedAt: Date.now() - 1000, + }), + ); + + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + const resp = await waitFor(() => findResponse(server.stdout, 1), 10000).catch((e) => { + throw new Error(`${(e as Error).message}\nstderr:\n${server.stderr.join('\n')}\ndaemon.log:\n${readDaemonLog(realRoot)}`); + }); + expect(resp.result.serverInfo.name).toBe('codegraph'); + await waitFor(() => countListeningLines(realRoot) >= 1, 10000); + // The pidfile now names a live daemon, not the planted-dead 999999. + const livePid = readLockPid(realRoot); + expect(livePid).not.toBe(999_999); + expect(isAlive(livePid!)).toBe(true); + }, 40000); + + it('proxy falls back to direct mode on a daemon version mismatch', async () => { + const net = await import('net'); + const sockPath = getDaemonSocketPath(realRoot); + // Plant a live-pid lockfile so the launcher treats the lock as held, and a + // mini-server that answers with a mismatched-version hello. + fs.writeFileSync( + path.join(realRoot, '.codegraph', 'daemon.pid'), + JSON.stringify({ pid: process.pid, version: '0.0.0-mismatch', socketPath: sockPath, startedAt: Date.now() }), + ); + const miniServer = net.createServer((sock) => { + sock.write(JSON.stringify({ codegraph: '0.0.0-mismatch', pid: 1, socketPath: sockPath, protocol: 1 }) + '\n'); + }); + await new Promise((resolve) => miniServer.listen(sockPath, () => resolve())); + + try { + const server = spawnServer(tempDir); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + // Despite the mismatched daemon, the client still gets an initialize + // response — the proxy answers the handshake locally and, refusing to + // attach across the version mismatch, serves the session in-process. + const resp = await waitFor(() => findResponse(server.stdout, 1), 10000); + expect(resp.result.serverInfo.name).toBe('codegraph'); + await waitFor( + () => server.stderr.some((l) => l.includes('serving this session in-process')), + 6000, + ); + } finally { + await new Promise((resolve) => miniServer.close(() => resolve())); + } + }, 30000); + + // The over-the-wire client-hello → record → sweep path is covered by the + // deterministic `Daemon.reapDeadClients` unit test in daemon-client-liveness + // (a raw-socket variant here was flaky under heavy parallel load), plus the + // client-hello round-trip exercised by every test above (the real proxy now + // sends it). What stays here is the lifecycle behavior that needs real procs. + it('exits on the inactivity backstop even while a client stays connected (#692)', async () => { + // Backstop short, idle timeout long: with a client connected the idle timer + // never arms, so only the inactivity backstop can take the daemon down. + const env = { CODEGRAPH_DAEMON_MAX_IDLE_MS: '1500', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '60000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + expect(isAlive(daemonPid)).toBe(true); + + // Send nothing further — the client stays connected but idle. The backstop + // should fire and the daemon should exit and clean up its lockfile. + expect(await waitProcessExit(daemonPid, 12000)).toBe(true); + expect(readDaemonLog(realRoot)).toContain('inactivity backstop'); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); + }, 30000); + + it('daemon idle-times-out after the last client disconnects', async () => { + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', CODEGRAPH_PPID_POLL_MS: '200' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + + // Close the only client's stdin → proxy exits → daemon refcount hits 0 → + // idle timer fires → daemon exits and cleans up its lockfile. + server.child.stdin.end(); + + expect(await waitProcessExit(daemonPid, 10000)).toBe(true); + expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false); + }, 30000); + + it('proxy survives the daemon dying mid-session and keeps serving (#662)', async () => { + // The #662 scenario: an MCP host SIGTERM's the shared daemon while a session + // is live. The proxy must NOT exit (losing CodeGraph for that session) — it + // falls back to an in-process engine and keeps answering. + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + + // A warm call goes through the daemon. + sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + await waitFor(() => findResponse(server.stdout, 2), 10000); + + // Kill the daemon out from under the live proxy. + process.kill(daemonPid, 'SIGTERM'); + expect(await waitProcessExit(daemonPid, 8000)).toBe(true); + + // The proxy must still be alive and still answer — served in-process now. + expect(isAlive(server.child.pid!)).toBe(true); + await waitFor(() => server.stderr.some((l) => l.includes('serving this session in-process')), 8000); + sendMessage(server.child, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); + const resp = await waitFor(() => findResponse(server.stdout, 3), 15000); + expect(resp.result !== undefined || resp.error !== undefined).toBe(true); + expect(isAlive(server.child.pid!)).toBe(true); + }, 45000); +}); diff --git a/__tests__/mcp-debounce-env.test.ts b/__tests__/mcp-debounce-env.test.ts new file mode 100644 index 000000000..82619973b --- /dev/null +++ b/__tests__/mcp-debounce-env.test.ts @@ -0,0 +1,47 @@ +/** + * CODEGRAPH_WATCH_DEBOUNCE_MS env override (issue #403). + * + * Lets users tune the watcher quiet window from MCP-launched configs without + * editing the agent's command line — formatter-on-save chains and large + * generated outputs benefit from a longer window. Clamped to [100ms, 60s]; + * out-of-range / non-numeric values fall back to the FileWatcher default + * (2000ms) rather than throwing or silently capping a likely typo. + */ +import { describe, it, expect } from 'vitest'; +import { parseDebounceEnv } from '../src/mcp/engine'; + +describe('parseDebounceEnv', () => { + it('returns undefined for unset / empty values', () => { + expect(parseDebounceEnv(undefined)).toBeUndefined(); + expect(parseDebounceEnv('')).toBeUndefined(); + expect(parseDebounceEnv(' ')).toBeUndefined(); + }); + + it('accepts integer values inside [100, 60000]', () => { + expect(parseDebounceEnv('100')).toBe(100); + expect(parseDebounceEnv('2000')).toBe(2000); + expect(parseDebounceEnv('5000')).toBe(5000); + expect(parseDebounceEnv('60000')).toBe(60000); + }); + + it('rejects out-of-range values (returns undefined, lets default win)', () => { + expect(parseDebounceEnv('0')).toBeUndefined(); + expect(parseDebounceEnv('50')).toBeUndefined(); // below 100 + expect(parseDebounceEnv('99')).toBeUndefined(); + expect(parseDebounceEnv('60001')).toBeUndefined(); // above 60s + expect(parseDebounceEnv('-500')).toBeUndefined(); + }); + + it('rejects non-integer / non-numeric values', () => { + expect(parseDebounceEnv('abc')).toBeUndefined(); + expect(parseDebounceEnv('500.5')).toBeUndefined(); + expect(parseDebounceEnv('NaN')).toBeUndefined(); + expect(parseDebounceEnv('Infinity')).toBeUndefined(); + }); + + it('accepts scientific notation that resolves to an in-range integer', () => { + // Number('1e3') === 1000, Number.isInteger(1000) === true. Power users + // who write debounce as 1e3 should not be surprised; the clamp still applies. + expect(parseDebounceEnv('1e3')).toBe(1000); + }); +}); diff --git a/__tests__/mcp-files-path-normalization.test.ts b/__tests__/mcp-files-path-normalization.test.ts new file mode 100644 index 000000000..94d870172 --- /dev/null +++ b/__tests__/mcp-files-path-normalization.test.ts @@ -0,0 +1,113 @@ +/** + * codegraph_files path-filter normalization (#426) + * + * Stored file paths are project-relative POSIX (e.g. "src/foo.ts"). Some + * agents pass project-root variants like "/", ".", "./" or "" when they want + * "the whole project", and Windows-style backslashes or leading "/" / "./" + * prefixes when they want a subtree. The old filter used a plain + * `startsWith(pathFilter)`, so any of those buried the agent at "no files + * found" and pushed it back to Read/Glob — the exact opencode regression in + * #426. These tests pin every branch of the normalization. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('codegraph_files path normalization', () => { + let tempDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-files-paths-')); + fs.mkdirSync(path.join(tempDir, 'src', 'components'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'index.ts'), `export const x = 1;\n`); + fs.writeFileSync( + path.join(tempDir, 'src', 'components', 'Button.ts'), + `export const Button = () => 1;\n` + ); + fs.writeFileSync(path.join(tempDir, 'tests', 'a.test.ts'), `export const t = 1;\n`); + cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.destroy(); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function listed(pathFilter: string | undefined): Promise { + const result = await handler.execute('codegraph_files', { + ...(pathFilter !== undefined ? { path: pathFilter } : {}), + format: 'flat', + includeMetadata: false, + }); + expect(result.isError).toBeFalsy(); + return result.content[0]!.text as string; + } + + // Root-ish filters: every shape an agent might guess for "whole project" + // must list the same files as no filter at all. + for (const rootish of ['/', '.', './', '', '\\', '//', './/']) { + it(`treats path=${JSON.stringify(rootish)} as project root`, async () => { + const output = await listed(rootish); + expect(output).toContain('src/index.ts'); + expect(output).toContain('src/components/Button.ts'); + expect(output).toContain('tests/a.test.ts'); + }); + } + + it('matches a real subdirectory prefix', async () => { + const output = await listed('src'); + expect(output).toContain('src/index.ts'); + expect(output).toContain('src/components/Button.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('tolerates a leading slash on a real subdirectory', async () => { + const output = await listed('/src'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('tolerates a leading "./" on a real subdirectory', async () => { + const output = await listed('./src'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('tolerates a trailing slash on a real subdirectory', async () => { + const output = await listed('src/'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('tests/a.test.ts'); + }); + + it('normalizes Windows backslashes', async () => { + const output = await listed('src\\components'); + expect(output).toContain('src/components/Button.ts'); + expect(output).not.toContain('src/index.ts'); + }); + + // Old code matched on raw `startsWith`, so a filter "src" would also + // return a sibling like "src-utils/...". The new code requires either an + // exact match or a "/" boundary, so prefixes don't bleed. + it('does not match sibling directories that share a prefix', async () => { + fs.mkdirSync(path.join(tempDir, 'src-utils'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src-utils', 'helper.ts'), `export const h = 1;\n`); + await cg.indexAll(); + + const output = await listed('src'); + expect(output).toContain('src/index.ts'); + expect(output).not.toContain('src-utils/helper.ts'); + }); +}); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 4a57ebae0..0a320773d 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -23,6 +23,14 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { return spawn(process.execPath, [BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], + // Pin to direct (in-process) mode. #172 is a contract about the in-process + // server's init ordering — the "File watcher active" log this test observes + // is emitted in-process. In daemon mode the watcher runs in the detached + // daemon (logging to .codegraph/daemon.log, not the child's stderr); the + // same response-before-init guarantee lives in the shared session code and + // is covered by mcp-daemon.test.ts. Direct mode also avoids leaking a + // detached daemon from this suite. + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, }) as ChildProcessWithoutNullStreams; } @@ -146,4 +154,30 @@ describe('MCP initialize handshake (issue #172)', () => { expect(json.id).toBe(0); expect(json.result.serverInfo.name).toBe('codegraph'); }, 20000); + + it('answers resources/list and prompts/list with empty lists, not -32601 (issue #621)', async () => { + child = spawnServer(tempDir); + const events = tagStreams(child); + sendInitialize(child, tempDir); + await waitFor(events, (e) => e.stream === 'stdout', 5000); // initialize reply + + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: {} }) + '\n'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'prompts/list', params: {} }) + '\n'); + + const replyFor = async (id: number) => { + const ev = await waitFor(events, (e) => { + if (e.stream !== 'stdout') return false; + try { return JSON.parse(e.text).id === id; } catch { return false; } + }, 5000); + return JSON.parse(ev.text); + }; + + const resources = await replyFor(1); + expect(resources.error).toBeUndefined(); + expect(resources.result.resources).toEqual([]); + + const prompts = await replyFor(2); + expect(prompts.error).toBeUndefined(); + expect(prompts.result.prompts).toEqual([]); + }, 15000); }); diff --git a/__tests__/mcp-ppid-watchdog.test.ts b/__tests__/mcp-ppid-watchdog.test.ts new file mode 100644 index 000000000..781e0be70 --- /dev/null +++ b/__tests__/mcp-ppid-watchdog.test.ts @@ -0,0 +1,173 @@ +/** + * PPID watchdog regression test (#277). + * + * On Linux, when an MCP host (Claude Code, opencode, …) is SIGKILL'd by the + * OOM killer / a force-quit / a container teardown, the kernel does NOT + * propagate the death to its `codegraph serve --mcp` child. The child gets + * reparented to init/systemd, its stdin stays half-open in some + * configurations, and the existing `stdin.on('end' | 'close')` handlers + * never fire — the server lingers indefinitely, holding inotify watches, + * file descriptors, and the SQLite WAL. + * + * `src/mcp/index.ts` polls `process.ppid` and shuts down the moment it + * diverges from the value observed at startup. This test stands up a + * four-tier process tree (vitest → wrapper → {stdin-holder, codegraph}) and + * SIGKILL's the wrapper. The stdin-holder is a long-lived sibling whose + * `stdout` pipe is dup'd into codegraph's `stdin`. After the wrapper dies + * the pipe stays open (stdin-holder still owns the write-end), so the + * existing stdin close handlers do **not** fire — the only thing that can + * terminate codegraph then is the PPID watchdog. + * + * Windows is excluded — `process.kill(pid, 'SIGKILL')` does not actually + * deliver SIGKILL there, and the per-OS reparenting semantics the watchdog + * relies on are POSIX-specific. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function waitForExit(pid: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (!isAlive(pid)) return resolve(true); + if (Date.now() - start > timeoutMs) return resolve(false); + setTimeout(tick, 100); + }; + tick(); + }); +} + +describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () => { + let wrapper: ChildProcessWithoutNullStreams | null = null; + let childPid: number | null = null; + let stdinHolderPid: number | null = null; + + afterEach(() => { + if (wrapper && !wrapper.killed) { + try { wrapper.kill('SIGKILL'); } catch { /* already gone */ } + } + // Belt and suspenders — don't leak processes if an assertion failed. + for (const pid of [childPid, stdinHolderPid]) { + if (pid !== null && isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } + } + } + wrapper = null; + childPid = null; + stdinHolderPid = null; + }); + + it("shuts down when its parent is SIGKILL'd and stdin stays open", async () => { + // The wrapper: + // 1. Spawns a "stdin-holder" — a tiny long-lived node process whose + // `stdout` pipe is dup'd into codegraph's `stdin`. As long as the + // stdin-holder is alive (it is — it's an orphan after the wrapper + // dies), codegraph's stdin never sees EOF. + // 2. Spawns codegraph with that pipe as fd 0 and its stderr redirected + // to a tmp file that survives the wrapper, then reports both PIDs. + // 3. Idles until SIGKILL'd from the test. + // + // CODEGRAPH_PPID_POLL_MS=200 keeps the watchdog responsive in test; the + // production default is 5000ms. + const stderrLog = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ppid-watchdog-')), + 'codegraph.stderr.log', + ); + // The wrapper waits 800ms before reporting the PIDs so the codegraph + // child has time to finish its async start() (dynamic import + transport + // setup + watchdog registration). Otherwise the test races: it + // SIGKILL's the wrapper before the watchdog interval is installed, and + // nothing terminates codegraph. + const wrapperSrc = ` + const { spawn } = require('child_process'); + const fs = require('fs'); + const stderrFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a'); + const stdinHolder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + stdio: ['ignore', 'pipe', 'ignore'], + detached: true, + }); + stdinHolder.unref(); + const child = spawn(process.execPath, [${JSON.stringify(BIN)}, 'serve', '--mcp'], { + stdio: [stdinHolder.stdout, 'ignore', stderrFd], + // Pin to direct (in-process) mode: this test targets the in-process + // server's PPID watchdog (#277). The detached-daemon/proxy watchdog is + // covered separately in mcp-daemon.test.ts ("daemon survives the first + // client dying"). Without this the spawned process becomes a proxy and + // also spawns a detached daemon that would outlive the test. + env: { ...process.env, CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_NO_DAEMON: '1' }, + detached: true, + }); + child.unref(); + setTimeout(() => { + process.stdout.write(JSON.stringify({ pid: child.pid, stdinHolderPid: stdinHolder.pid }) + '\\n'); + }, 800); + setInterval(() => {}, 60000); + `; + wrapper = spawn(process.execPath, ['-e', wrapperSrc], { + stdio: ['pipe', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams; + + const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => { + let buf = ''; + const timer = setTimeout( + () => reject(new Error('wrapper did not report PIDs in time')), + 10000, + ); + wrapper!.stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString('utf8'); + const m = buf.match(/\{"pid":(\d+),"stdinHolderPid":(\d+)\}/); + if (m) { + clearTimeout(timer); + resolve({ pid: parseInt(m[1], 10), stdinHolderPid: parseInt(m[2], 10) }); + } + }); + wrapper!.on('exit', () => { + clearTimeout(timer); + reject(new Error('wrapper exited before reporting PIDs')); + }); + }); + childPid = pids.pid; + stdinHolderPid = pids.stdinHolderPid; + + expect(isAlive(childPid)).toBe(true); + expect(isAlive(stdinHolderPid)).toBe(true); + + // SIGKILL the wrapper — no cleanup runs, just like a real OOM kill. + // codegraph and the stdin-holder both get reparented to init/systemd. + // Crucially, the pipe between them stays open, so codegraph's stdin + // doesn't close: only the watchdog can take it down. + wrapper.kill('SIGKILL'); + + // Watchdog runs every 200ms in this test → 5s gives ~25 polls of headroom. + const exited = await waitForExit(childPid, 5000); + const stderrContent = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : ''; + expect( + exited, + `codegraph child (pid=${childPid}) did not exit within 5s after wrapper was SIGKILL'd.\nstderr:\n${stderrContent}`, + ).toBe(true); + // The watchdog announces itself before tearing down — assert that the + // shutdown came from the parent-death path, not from any other signal. + expect(stderrContent).toMatch(/Parent process exited.*shutting down/); + + // The stdin-holder is now an orphan — kill it explicitly so it doesn't + // outlive the test. It's still tracked in `stdinHolderPid` for the + // afterEach safety net, but we tidy up proactively here too. + if (isAlive(stdinHolderPid)) { + try { process.kill(stdinHolderPid, 'SIGKILL'); } catch { /* race */ } + } + }, 20000); +}); diff --git a/__tests__/mcp-staleness-banner.test.ts b/__tests__/mcp-staleness-banner.test.ts new file mode 100644 index 000000000..5ea5a0103 --- /dev/null +++ b/__tests__/mcp-staleness-banner.test.ts @@ -0,0 +1,173 @@ +/** + * Per-file staleness banner on MCP tool responses (issue #403). + * + * The watcher tracks every file event since the last successful sync; the + * tool dispatcher intersects "files referenced in this response" with that + * pending set and prepends a banner ("⚠️ Some files referenced below were + * edited since the last index sync…") plus an optional footer ("(Note: N + * file(s) elsewhere in this project are pending index sync…)"). + * + * No auto-flush, no static wait — the response is instant and the agent + * decides whether to Read the specific stale file. These tests exercise + * the full real path: real CodeGraph index + real ToolHandler.execute(). + * + * **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the + * real native fs.watch (FSEvents/inotify) delivery is non-deterministic under + * parallel vitest execution and produced a consistent ~30% failure rate on + * these tests when run inside the full suite. The seam drives the watcher's + * pending-set pipeline directly so the tests synthesize file events + * deterministically. The watcher's actual debounce timer (real setTimeout) is + * left untouched. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import { __emitWatchEventForTests } from '../src/sync/watcher'; + +function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + if (condition()) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out')); + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +describe('MCP staleness banner', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-')); + fs.mkdirSync(path.join(testDir, 'src')); + // Three isolated files with no cross-references — keeps each test's + // "which path does the response mention?" assertion unambiguous. If the + // files shared imports/calls, codegraph_search responses would surface + // multiple file paths and the banner-vs-footer split would be racy. + fs.writeFileSync( + path.join(testDir, 'src', 'alpha-only.ts'), + 'export function alphaOnly() { return 1; }\n', + ); + fs.writeFileSync( + path.join(testDir, 'src', 'bravo-only.ts'), + 'export function bravoOnly() { return 2; }\n', + ); + fs.writeFileSync( + path.join(testDir, 'src', 'charlie-only.ts'), + 'export function charlieOnly() { return 3; }\n', + ); + + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }); + + afterEach(() => { + try { cg.unwatch(); } catch { /* ignore */ } + try { cg.close(); } catch { /* ignore */ } + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('prepends a stale banner when the response references a pending file', async () => { + // Long debounce so the edit lingers in pendingFiles while we query. + cg.watch({ debounceMs: 4000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + // Real disk write so a later sync (if it fires) sees the new content, + // plus a synthesized chokidar event so the watcher's pendingFiles set + // updates immediately without waiting on OS-level event delivery. + fs.writeFileSync( + path.join(testDir, 'src', 'alpha-only.ts'), + 'export function alphaOnly() { return 99; }\n', + ); + __emitWatchEventForTests(testDir, 'src/alpha-only.ts'); + + // With mocked chokidar this is synchronous — keep the wait just to + // exercise the realistic shape (the watcher's `chokidarReady` gate + // and the small window before the pending-file Map is populated). + await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts')); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + expect(res.isError).toBeFalsy(); + const text = res.content[0].text; + + // Banner shape: warning glyph + filename + actionable instruction. + expect(text.startsWith('⚠️')).toBe(true); + expect(text).toContain('src/alpha-only.ts'); + expect(text).toMatch(/edited \d+ms ago/); + expect(text).toMatch(/Read them directly/); + // The actual result must still follow the banner. + expect(text).toMatch(/alphaOnly/); + }); + + it('uses the footer (not the banner) when pending files are not referenced', async () => { + cg.watch({ debounceMs: 4000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is + // only in alpha-only.ts. The two files share no imports/calls so the + // response text won't mention bravo-only.ts. + fs.writeFileSync( + path.join(testDir, 'src', 'bravo-only.ts'), + 'export function bravoOnly() { return 22; }\n', + ); + __emitWatchEventForTests(testDir, 'src/bravo-only.ts'); + await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts')); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + const text = res.content[0].text; + + expect(text.startsWith('⚠️')).toBe(false); + expect(text).toMatch(/elsewhere in this project are pending index sync/); + expect(text).toContain('src/bravo-only.ts'); + }); + + it('drops the banner once the sync completes and clears the pending entry', async () => { + cg.watch({ debounceMs: 200, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + fs.writeFileSync( + path.join(testDir, 'src', 'alpha-only.ts'), + 'export function alphaOnly() { return 7; }\n', + ); + __emitWatchEventForTests(testDir, 'src/alpha-only.ts'); + // Wait through debounce (200ms) + sync; pendingFiles drains back to empty. + await waitFor(() => cg.getPendingFiles().length === 0, 3000); + + const res = await handler.execute('codegraph_search', { query: 'alphaOnly' }); + const text = res.content[0].text; + expect(text.startsWith('⚠️')).toBe(false); + expect(text).not.toMatch(/elsewhere in this project are pending index sync/); + }); + + it('lists pending files under "Pending sync" in codegraph_status', async () => { + cg.watch({ debounceMs: 4000, inertForTests: true }); + await cg.waitUntilWatcherReady(); + + fs.writeFileSync( + path.join(testDir, 'src', 'charlie-only.ts'), + 'export function charlieOnly() { return 33; }\n', + ); + __emitWatchEventForTests(testDir, 'src/charlie-only.ts'); + await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts')); + + const res = await handler.execute('codegraph_status', {}); + const text = res.content[0].text; + expect(text).toContain('### Pending sync:'); + expect(text).toContain('src/charlie-only.ts'); + // Status embeds the info first-class, so the auto-banner is suppressed. + expect(text.startsWith('⚠️')).toBe(false); + }); + + it('returns zero pending files when no watcher is active', () => { + expect(cg.getPendingFiles()).toEqual([]); + }); +}); diff --git a/__tests__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts new file mode 100644 index 000000000..08067c918 --- /dev/null +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -0,0 +1,69 @@ +/** + * CODEGRAPH_MCP_TOOLS allowlist — lets an operator (or an A/B harness) trim the + * exposed MCP tool surface without touching the client config. Inert when unset. + * Filtering happens in ListTools (getTools) and is enforced again on execute(). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { ToolHandler } from '../src/mcp/tools'; + +const ENV = 'CODEGRAPH_MCP_TOOLS'; + +describe('CODEGRAPH_MCP_TOOLS allowlist', () => { + const original = process.env[ENV]; + afterEach(() => { + if (original === undefined) delete process.env[ENV]; + else process.env[ENV] = original; + }); + + const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); + + it('exposes the default 4-tool surface when unset', () => { + delete process.env[ENV]; + // The default set (see DEFAULT_MCP_TOOLS): explore + node are the + // validated workhorses, search the cheap lookup, callers the one + // irreplaceable enumerator. callees/impact/files/status stay defined + // and executable but unlisted — impact appeared in ZERO recorded runs. + expect(listed()).toEqual([ + 'codegraph_callers', + 'codegraph_explore', + 'codegraph_node', + 'codegraph_search', + ]); + }); + + it('re-enables an unlisted tool via the allowlist (impact)', () => { + process.env[ENV] = 'explore,impact'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_impact']); + }); + + it('filters ListTools to the allowlisted short names', () => { + process.env[ENV] = 'explore,search,node'; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_node', 'codegraph_search']); + }); + + it('accepts fully-qualified codegraph_ names and ignores whitespace', () => { + process.env[ENV] = ' codegraph_explore , search '; + expect(listed()).toEqual(['codegraph_explore', 'codegraph_search']); + }); + + it('treats an empty/whitespace value as unset (default surface)', () => { + process.env[ENV] = ' '; + expect(listed()).toHaveLength(4); + expect(listed()).toContain('codegraph_explore'); + }); + + it('rejects a disabled tool on execute (defense in depth)', async () => { + process.env[ENV] = 'node'; + const res = await new ToolHandler(null).execute('codegraph_explore', {}); + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/disabled via CODEGRAPH_MCP_TOOLS/); + }); + + it('lets an allowlisted tool past the guard', async () => { + process.env[ENV] = 'search'; + // No CodeGraph attached, so it fails *after* the allowlist guard — the + // "disabled" message must NOT appear, proving the guard passed it through. + const res = await new ToolHandler(null).execute('codegraph_search', { query: 'x' }); + expect(res.content[0].text).not.toMatch(/disabled via CODEGRAPH_MCP_TOOLS/); + }); +}); diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts new file mode 100644 index 000000000..2b0019d6d --- /dev/null +++ b/__tests__/mcp-unindexed.test.ts @@ -0,0 +1,225 @@ +/** + * Unindexed-workspace session policy tests. + * + * An MCP session attached to a workspace with no .codegraph/ must go quiet + * rather than fail loudly: `initialize` returns the short "inactive" + * instructions variant (not the full playbook), `tools/list` returns an + * EMPTY list, and a tool call that still arrives (cross-project + * `projectPath`, or a host that skips tools/list) answers with a + * SUCCESS-shaped guidance message — never `isError: true`. One or two early + * isError responses teach an agent to abandon codegraph for the whole + * session; that observed failure mode is what this suite guards. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Direct (in-process) mode — the unindexed path never has a daemon + // anyway (the daemon socket lives in .codegraph/), and this keeps the + // suite from leaking a detached daemon in the indexed test. + // CODEGRAPH_WASM_RELAUNCHED skips the --liftoff-only re-exec: without + // it the server runs as a GRANDCHILD that survives child.kill() on + // Windows and holds the temp cwd/SQLite handles, failing teardown with + // EPERM no matter how long rmSync retries (the class documented for + // the mcp-initialize/mcp-roots suites). + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + }) as ChildProcessWithoutNullStreams; +} + +/** Send a JSON-RPC request and resolve with the response matching its id. */ +function request( + child: ChildProcessWithoutNullStreams, + msg: { id: number; method: string; params?: unknown }, + timeoutMs = 15000 +): Promise> { + return new Promise((resolve, reject) => { + let buf = ''; + const timer = setTimeout(() => { + child.stdout.off('data', onData); + reject(new Error(`timeout waiting for response id=${msg.id}`)); + }, timeoutMs); + const onData = (chunk: Buffer) => { + buf += chunk.toString(); + let idx: number; + while ((idx = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + if (parsed.id === msg.id) { + clearTimeout(timer); + child.stdout.off('data', onData); + resolve(parsed); + return; + } + } catch { + // non-JSON noise on stdout — ignore + } + } + }; + child.stdout.on('data', onData); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n'); + }); +} + +function initializeParams(projectPath: string) { + return { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }; +} + +describe('Unindexed-workspace session policy', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-')); + }); + + afterEach(async () => { + if (child) { + // Wait for the child to actually exit before removing its cwd — on + // Windows a just-killed process briefly holds the directory/SQLite + // handles, and an immediate rmSync fails the teardown with EPERM + // (the documented file-locking class that fails the sibling + // mcp-initialize/mcp-roots suites). kill + await exit + retried + // removal keeps this suite green on Windows. + const exited = new Promise((resolve) => child!.once('exit', () => resolve())); + child.kill('SIGKILL'); + await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + }); + + it('initialize returns the short "inactive" instructions, not the playbook', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n'); + child = spawnServer(tempDir); + + const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (res.result as { instructions: string }).instructions; + + expect(instructions).toMatch(/inactive/i); + expect(instructions).toMatch(/codegraph init/); + // The full playbook must NOT be sent into a session where every call fails + expect(instructions).not.toMatch(/Tool selection by intent/); + expect(instructions).not.toMatch(/codegraph_explore/); + }); + + it('tools/list returns an EMPTY list when the workspace has no index', async () => { + child = spawnServer(tempDir); + await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + + const res = await request(child, { id: 1, method: 'tools/list' }); + expect((res.result as { tools: unknown[] }).tools).toEqual([]); + }); + + it('an INDEXED workspace still gets the full playbook and all tools', async () => { + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n'); + const cg = await CodeGraph.init(tempDir, { index: true }); + cg.close(); + + child = spawnServer(tempDir); + const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) }); + const instructions = (init.result as { instructions: string }).instructions; + expect(instructions).toMatch(/Tool selection by intent/); + expect(instructions).not.toMatch(/inactive/i); + + const list = await request(child, { id: 1, method: 'tools/list' }); + const tools = (list.result as { tools: Array<{ name: string }> }).tools; + // A 1-file project triggers the pre-existing tiny-repo tool gating (a + // reduced core set) — the contract under test is "indexed → tools are + // PRESENT", in contrast to the unindexed empty list above. + expect(tools.length).toBeGreaterThanOrEqual(3); + expect(tools.map((t) => t.name)).toContain('codegraph_explore'); + }); +}); + +describe('No-error policy on expected conditions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: tempDir, + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/isn't indexed/); + expect(res.content[0]!.text).toMatch(/codegraph init/); + expect(res.content[0]!.text).toMatch(/built-in tools/); + }); + + it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/); + expect(res.content[0]!.text).toMatch(/projectPath/); + }); + + it.runIf(process.platform !== 'win32')( + 'sensitive-path refusal stays a hard error (no retry encouragement)', + async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: '/etc', + }); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).not.toMatch(/retry the call once/); + } + ); +}); + +describe('search kind filter', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-')); + fs.writeFileSync( + path.join(tempDir, 'types.ts'), + 'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n' + ); + cg = await CodeGraph.init(tempDir, { index: true }); + }); + + afterEach(() => { + cg.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("kind: 'type' (the advertised enum value) finds type aliases", async () => { + const res = await new ToolHandler(cg).execute('codegraph_search', { + query: 'PaymentMethod', + kind: 'type', + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/PaymentMethod/); + expect(res.content[0]!.text).not.toMatch(/No results found/); + }); +}); diff --git a/__tests__/node-file-view.test.ts b/__tests__/node-file-view.test.ts new file mode 100644 index 000000000..7d2a5703c --- /dev/null +++ b/__tests__/node-file-view.test.ts @@ -0,0 +1,118 @@ +/** + * codegraph_node FILE READ mode: a `file` with no `symbol` reads that file like + * the Read tool — current source with `\t` numbering (byte-for-byte + * Read's shape), narrowable with offset/limit — plus a one-line blast-radius + * header. `symbolsOnly` returns the structural map instead. Config/data files + * are summarized by key, never dumped (#383). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('codegraph_node file-view (Read replacement)', () => { + let dir: string; + let cg: CodeGraph; + let h: ToolHandler; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fileview-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'a.ts'), + 'export function helper(x: number) {\n return x + 1;\n}\nexport class Widget {\n build() { return helper(1); }\n}\n', + ); + fs.writeFileSync( + path.join(dir, 'src', 'b.ts'), + "import { helper } from './a';\n\n// a comment between symbols\nconst SETTING = 7;\nexport function useHelper() { return helper(2) + SETTING; }\n", + ); + // A config/data file (#383): its values may be secrets and must never be + // dumped verbatim by the file-view. + fs.writeFileSync( + path.join(dir, 'src', 'application.properties'), + 'spring.datasource.password=SUPERSECRET123\nserver.port=8080\n', + ); + // A large file: exceeds the file-view line budget, so it must be windowed + // honestly (not silently truncated). + fs.writeFileSync( + path.join(dir, 'src', 'big.ts'), + 'export function big() {\n' + + Array.from({ length: 2000 }, (_, i) => ` const v${i} = ${i};`).join('\n') + + '\n return 0;\n}\n', + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.properties'], exclude: [] } }); + await cg.indexAll(); + h = new ToolHandler(cg); + }); + + afterEach(() => { + if (cg) cg.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const text = async (args: Record): Promise => + (await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n'); + + it('reads a whole file like Read by default — `\\t` lines (no pad), imports + gaps included', async () => { + const out = await text({ file: 'b.ts' }); // no includeCode needed — content is the default + // Byte-for-byte Read shape: line 1 is "1import …", NOT space-padded. + expect(out).toMatch(/^1\timport \{ helper \} from '\.\/a';$/m); + expect(out).toContain('// a comment between symbols'); // inter-symbol gap (Read has it; old reconstruction dropped it) + expect(out).toContain('const SETTING = 7'); // top-level statement + expect(out).toContain('useHelper'); // the symbol body too + expect(out).not.toContain('```'); // Read has no code fence; neither do we + }); + + it('leads with a one-line blast-radius header (the value-add over Read)', async () => { + const out = await text({ file: 'a.ts' }); + expect(out).toMatch(/used by 1 file: src\/b\.ts/); // a.ts is imported by b.ts + expect(out).toContain('return x + 1'); // still returns the source + }); + + it('offset/limit narrow the window exactly like Read', async () => { + const out = await text({ file: 'big.ts', offset: 1000, limit: 3 }); + // Window starts at the requested line, numbered exactly: "1000 const v998 = 998;" + expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m); + expect(out).not.toMatch(/^1\t/m); // line 1 is NOT shown + expect(out).toMatch(/lines 1000[–-]1002 of \d+/); // honest pagination note + }); + + it('an offset past EOF is reported, not a crash', async () => { + const out = await text({ file: 'a.ts', offset: 9999 }); + expect(out).toMatch(/past the end/i); + }); + + it('paginates a large file honestly by default — "lines 1–N of TOTAL", never a silent truncate', async () => { + const out = await text({ file: 'big.ts' }); + expect(out).toMatch(/lines 1[–-]\d+ of \d+/); // explicit window note + expect(out).not.toContain('(output truncated)'); // not the generic 15k chop + expect(out).toMatch(/^1\texport function big/m); // the head of the window is real source + }); + + it('does NOT dump a config/data file (yaml/properties) — #383 secret safety', async () => { + const out = await text({ file: 'application.properties' }); + expect(out).not.toContain('SUPERSECRET123'); // the value never reaches the agent + expect(out.toLowerCase()).toMatch(/config|values withheld/); + }); + + it('symbolsOnly returns the structural map, not the source', async () => { + const out = await text({ file: 'a.ts', symbolsOnly: true }); + expect(out).toContain('### Symbols'); + expect(out).toContain('helper'); + expect(out).toContain('Widget'); + expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map + }); + + it('still works as a normal symbol lookup (no regression)', async () => { + const out = await text({ symbol: 'helper', includeCode: true }); + expect(out).toContain('helper'); + expect(out).toContain('return x + 1'); + }); + + it('a miss returns a helpful message, not a crash', async () => { + const out = await text({ file: 'does-not-exist.ts' }); + expect(out).toMatch(/no indexed file matches/i); + }); +}); diff --git a/__tests__/node-sqlite-backend.test.ts b/__tests__/node-sqlite-backend.test.ts new file mode 100644 index 000000000..d1e630f62 --- /dev/null +++ b/__tests__/node-sqlite-backend.test.ts @@ -0,0 +1,71 @@ +/** + * node:sqlite backend (issue #238 follow-up). + * + * node:sqlite (Node's built-in real SQLite) is now the sole backend. This drives + * a real index + queries through it, so WAL, FTS5 search, and @named-param + * writes are all exercised end-to-end. + * + * Skipped on Node < 22.5 where node:sqlite doesn't exist. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src'; + +let nodeSqliteAvailable = false; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('node:sqlite'); + nodeSqliteAvailable = true; +} catch { + nodeSqliteAvailable = false; +} + +describe.skipIf(!nodeSqliteAvailable)('node:sqlite backend — real index + queries', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nodesqlite-')); + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n'); + fs.writeFileSync( + path.join(dir, 'b.ts'), + "import { helper } from './a';\nexport function main(): number { return helper(); }\n" + ); + cg = await CodeGraph.init(dir, { index: true }); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses the node:sqlite backend', () => { + expect(cg.getBackend()).toBe('node-sqlite'); + }); + + it('runs in WAL mode — the whole reason it beats the wasm fallback', () => { + expect(cg.getJournalMode()).toBe('wal'); + }); + + it('indexed the project (write path: @named-param INSERTs via node:sqlite)', () => { + const stats = cg.getStats(); + expect(stats.fileCount).toBe(2); + expect(stats.nodeCount).toBeGreaterThan(0); + }); + + it('FTS5 search returns the indexed symbol (read path)', () => { + const results = cg.searchNodes('helper'); + const names = results.map(r => r.node.name); + expect(names).toContain('helper'); + }); + + it('graph traversal resolves the cross-file caller', () => { + const helper = cg.searchNodes('helper').find(r => r.node.name === 'helper'); + expect(helper).toBeTruthy(); + const callers = cg.getCallers(helper!.node.id); + expect(callers.map(c => c.node.name)).toContain('main'); + }); +}); diff --git a/__tests__/node-version-check.test.ts b/__tests__/node-version-check.test.ts index d7b725cbb..fc455eb85 100644 --- a/__tests__/node-version-check.test.ts +++ b/__tests__/node-version-check.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from 'vitest'; -import { buildNode25BlockBanner } from '../src/bin/node-version-check'; +import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from '../src/bin/node-version-check'; describe('buildNode25BlockBanner', () => { it('embeds the reported Node version in the header', () => { @@ -41,3 +41,29 @@ describe('buildNode25BlockBanner', () => { ); }); }); + +describe('buildNodeTooOldBanner', () => { + it('embeds the reported Node version in the header', () => { + expect(buildNodeTooOldBanner('18.20.0')).toContain( + 'Unsupported Node.js version: 18.20.0' + ); + }); + + it('states the supported floor matching MIN_NODE_MAJOR', () => { + expect(MIN_NODE_MAJOR).toBe(20); + expect(buildNodeTooOldBanner('18.0.0')).toContain( + `requires Node.js ${MIN_NODE_MAJOR} or newer` + ); + }); + + it('points users to Node 22 LTS via nvm and Homebrew', () => { + const banner = buildNodeTooOldBanner('16.0.0'); + expect(banner).toContain('Node.js 22 LTS'); + expect(banner).toContain('nvm install 22'); + expect(banner).toContain('brew install node@22'); + }); + + it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => { + expect(buildNodeTooOldBanner('18.0.0')).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1'); + }); +}); diff --git a/__tests__/npm-sdk.test.ts b/__tests__/npm-sdk.test.ts new file mode 100644 index 000000000..c27bfc5fe --- /dev/null +++ b/__tests__/npm-sdk.test.ts @@ -0,0 +1,107 @@ +/** + * Programmatic/embedded SDK entry (`scripts/npm-sdk.js`) tests (issue #354). + * + * The published main package is a thin shim: the CLI `bin` (npm-shim.js) execs + * the bundled Node, while `main` (npm-sdk.js) lets embedded consumers + * `require("@colbymchenry/codegraph")` on their OWN Node by re-exporting the + * compiled library that ships inside the per-platform optionalDependency + * (@colbymchenry/codegraph-/lib/dist/index.js). + * + * These tests stand up a temp main-package dir with a fake platform package as a + * resolvable sibling, then require the SDK in a child process — so resolution, + * the self-heal cache fallback, and the missing-bundle error are exercised + * hermetically with no real bundle, network, or registry. + */ + +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const SDK_SRC = path.join(__dirname, '..', 'scripts', 'npm-sdk.js'); +const target = `${process.platform}-${process.arch}`; +const VERSION = '9.9.9-test'; + +function mkTmp(label: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `cg-sdk-${label}-`)); +} + +// A temp node_modules with the main package (npm-sdk.js + package.json). The +// fake platform package, when present, is written as a resolvable sibling so the +// SDK's `require.resolve('@colbymchenry/codegraph-/...')` walks to it. +function makeConsumer(): { root: string; mainPkg: string } { + const root = mkTmp('consumer'); + const mainPkg = path.join(root, 'node_modules', '@colbymchenry', 'codegraph'); + fs.mkdirSync(mainPkg, { recursive: true }); + fs.copyFileSync(SDK_SRC, path.join(mainPkg, 'npm-sdk.js')); + fs.writeFileSync( + path.join(mainPkg, 'package.json'), + JSON.stringify({ name: '@colbymchenry/codegraph', version: VERSION, main: 'npm-sdk.js' }) + '\n' + ); + return { root, mainPkg }; +} + +// Write a fake compiled library that exports a sentinel, at the given lib/dist +// root (used both for the platform package and the self-heal cache bundle). +function writeFakeLib(libDistDir: string, sentinel: string): void { + fs.mkdirSync(libDistDir, { recursive: true }); + fs.writeFileSync( + path.join(libDistDir, 'index.js'), + `module.exports = { SENTINEL: ${JSON.stringify(sentinel)}, CodeGraph: function CodeGraph() {} };\n` + ); +} + +function installPlatformPackage(root: string, sentinel: string): void { + const pkgRoot = path.join(root, 'node_modules', '@colbymchenry', `codegraph-${target}`); + writeFakeLib(path.join(pkgRoot, 'lib', 'dist'), sentinel); + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: VERSION }) + '\n' + ); +} + +// require() the SDK in a child process so each case gets a fresh module cache. +function requireSdk(mainPkg: string, env: Record = {}) { + const code = + `try { const m = require(${JSON.stringify(path.join(mainPkg, 'npm-sdk.js'))});` + + ` process.stdout.write(JSON.stringify({ sentinel: m.SENTINEL, cg: typeof m.CodeGraph })); }` + + ` catch (e) { process.stderr.write(String(e && e.message || e)); process.exit(7); }`; + const r = spawnSync(process.execPath, ['-e', code], { + encoding: 'utf8', + env: { ...process.env, ...env }, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +describe('npm-sdk programmatic entry', () => { + it('re-exports the installed platform bundle library', () => { + const { root, mainPkg } = makeConsumer(); + installPlatformPackage(root, 'platform-lib'); + // Isolate from any real self-healed cache on this machine. + const r = requireSdk(mainPkg, { CODEGRAPH_INSTALL_DIR: path.join(root, '.empty-cache') }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ sentinel: 'platform-lib', cg: 'function' }); + }); + + it('falls back to a self-healed cache bundle when the optional dep is absent', () => { + const { root, mainPkg } = makeConsumer(); // no platform package installed + const cacheDir = path.join(root, 'cache'); + writeFakeLib( + path.join(cacheDir, 'bundles', `${target}-${VERSION}`, 'lib', 'dist'), + 'cache-lib' + ); + const r = requireSdk(mainPkg, { CODEGRAPH_INSTALL_DIR: cacheDir }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ sentinel: 'cache-lib', cg: 'function' }); + }); + + it('throws an actionable error when no bundle is installed or cached', () => { + const { root, mainPkg } = makeConsumer(); // no platform package, empty cache + const r = requireSdk(mainPkg, { CODEGRAPH_INSTALL_DIR: path.join(root, '.empty-cache') }); + expect(r.status).toBe(7); + expect(r.stderr).toContain(`@colbymchenry/codegraph-${target}`); + expect(r.stderr).toContain('not installed'); + expect(r.stderr).toContain('registry.npmjs.org'); + }); +}); diff --git a/__tests__/npm-shim.test.ts b/__tests__/npm-shim.test.ts new file mode 100644 index 000000000..16e70506a --- /dev/null +++ b/__tests__/npm-shim.test.ts @@ -0,0 +1,208 @@ +/** + * npm thin-installer launcher (`scripts/npm-shim.js`) tests. + * + * The shim runs on the user's own Node, locates the per-platform optionalDependency + * bundle, and — when a registry mirror failed to deliver it (issue #303) — falls + * back to downloading the bundle from GitHub Releases. These tests exercise that + * shim as a real subprocess from a temp "main package" dir (its own package.json + * + node_modules), so resolution and version lookup behave hermetically. + * + * The download/checksum paths run against a local self-signed HTTPS server via + * CODEGRAPH_DOWNLOAD_BASE — no real network, no published release needed. The + * shim is launched with async `spawn` (not spawnSync), so the test's event loop + * stays free to serve those requests. + * + * POSIX only: the fake bundle launcher is a shell script and extraction uses the + * system `tar`. Skipped on Windows (where the shim's exec path differs anyway). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, execSync } from 'child_process'; +import * as https from 'https'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { AddressInfo } from 'net'; + +const SHIM_SRC = path.join(__dirname, '..', 'scripts', 'npm-shim.js'); +const target = `${process.platform}-${process.arch}`; +const asset = `codegraph-${target}.tar.gz`; +const isWindows = process.platform === 'win32'; + +function hasOpenssl(): boolean { + try { execSync('openssl version', { stdio: 'ignore' }); return true; } catch { return false; } +} +const CAN_NET = !isWindows && hasOpenssl(); + +function mkTmp(label: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `cg-shim-${label}-`)); +} + +// A temp dir standing in for the installed @colbymchenry/codegraph main package. +function makePkg(version = '9.9.9-test'): string { + const dir = mkTmp('pkg'); + fs.copyFileSync(SHIM_SRC, path.join(dir, 'npm-shim.js')); + fs.writeFileSync(path.join(dir, 'package.json'), + JSON.stringify({ name: '@colbymchenry/codegraph', version }) + '\n'); + return dir; +} + +// A fake bundle launcher that prints a marker + its args, so we can prove the +// shim found and exec'd it (and passed args through). +function writeLauncher(binDir: string): void { + fs.mkdirSync(binDir, { recursive: true }); + const p = path.join(binDir, 'codegraph'); + fs.writeFileSync(p, '#!/bin/sh\necho "FAKE_BUNDLE_RAN args:$*"\n'); + fs.chmodSync(p, 0o755); +} + +// Launch the shim with async spawn so the in-process HTTPS server can respond +// while it runs (spawnSync would block this event loop and deadlock). +function runShim(pkgDir: string, args: string[], env: Record) { + return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, [path.join(pkgDir, 'npm-shim.js'), ...args], { + env: { ...process.env, ...env }, + }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); +} + +describe.skipIf(isWindows)('npm-shim launcher', () => { + it('runs the installed optional-dependency bundle without any download', async () => { + const pkg = makePkg(); + const platformPkg = path.join(pkg, 'node_modules', '@colbymchenry', `codegraph-${target}`); + writeLauncher(path.join(platformPkg, 'bin')); + fs.writeFileSync(path.join(platformPkg, 'package.json'), + JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: '9.9.9-test' }) + '\n'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--probe-abc'], { CODEGRAPH_INSTALL_DIR: cache }); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + expect(r.stdout).toContain('--probe-abc'); // args passed through + expect(r.stderr).not.toContain('downloading'); // never reached the fallback + expect(fs.existsSync(path.join(cache, 'bundles'))).toBe(false); + }); + + it('uses an already-cached bundle even when downloads are disabled', async () => { + const pkg = makePkg('1.2.3-cached'); + const cache = mkTmp('cache'); + writeLauncher(path.join(cache, 'bundles', `${target}-1.2.3-cached`, 'bin')); + const r = await runShim(pkg, ['--probe-xyz'], { + CODEGRAPH_INSTALL_DIR: cache, + CODEGRAPH_NO_DOWNLOAD: '1', + }); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + expect(r.stdout).toContain('--probe-xyz'); + expect(r.stderr).toBe(''); + }); + + it('prints actionable guidance and exits 1 when disabled with no bundle', async () => { + const pkg = makePkg(); + const r = await runShim(pkg, ['--version'], { + CODEGRAPH_INSTALL_DIR: mkTmp('cache'), + CODEGRAPH_NO_DOWNLOAD: '1', + }); + + expect(r.status).toBe(1); + expect(r.stderr).toContain(`no prebuilt bundle for ${target}`); + expect(r.stderr).toContain(`@colbymchenry/codegraph-${target}`); + expect(r.stderr).toContain('--registry=https://registry.npmjs.org'); + expect(r.stderr).toContain('install.sh'); + }); +}); + +describe.skipIf(!CAN_NET)('npm-shim download fallback (local HTTPS)', () => { + let server: https.Server; + let port = 0; + let fixtureBytes: Buffer; + let fixtureSha: string; + let sumsBody: string | null = null; // per-test: SHA256SUMS contents, or null for 404 + + beforeAll(async () => { + // Self-signed cert for the mock release host. + const cdir = mkTmp('tls'); + const keyP = path.join(cdir, 'key.pem'); + const certP = path.join(cdir, 'cert.pem'); + execSync( + `openssl req -x509 -newkey rsa:2048 -nodes -keyout ${keyP} -out ${certP} -days 1 -subj "/CN=localhost"`, + { stdio: 'ignore' }, + ); + + // Build a fake bundle archive (codegraph-/bin/codegraph), like a real release asset. + const work = mkTmp('fixture'); + writeLauncher(path.join(work, `codegraph-${target}`, 'bin')); + const archive = path.join(work, asset); + execSync(`tar -czf ${JSON.stringify(archive)} -C ${JSON.stringify(work)} codegraph-${target}`); + fixtureBytes = fs.readFileSync(archive); + fixtureSha = crypto.createHash('sha256').update(fixtureBytes).digest('hex'); + + server = https.createServer({ key: fs.readFileSync(keyP), cert: fs.readFileSync(certP) }, (req, res) => { + const url = req.url || ''; + if (url.endsWith(`/${asset}`)) { + res.writeHead(200); res.end(fixtureBytes); + } else if (url.endsWith('/SHA256SUMS')) { + if (sumsBody === null) { res.writeHead(404); res.end('not found'); } + else { res.writeHead(200); res.end(sumsBody); } + } else { + res.writeHead(404); res.end('not found'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + }, 30000); + + afterAll(() => { server?.close(); }); + + function netEnv(cache: string): Record { + return { + CODEGRAPH_INSTALL_DIR: cache, + CODEGRAPH_DOWNLOAD_BASE: `https://127.0.0.1:${port}`, + NODE_TLS_REJECT_UNAUTHORIZED: '0', + }; + } + + it('downloads, verifies the checksum, extracts, and execs the bundle', async () => { + sumsBody = `${fixtureSha} ${asset}\n`; + const pkg = makePkg('5.0.0-net'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--probe-net'], netEnv(cache)); + + expect(r.stderr).toContain('downloading'); + expect(r.stderr).toContain('checksum verified'); + expect(r.status).toBe(0); + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + expect(r.stdout).toContain('--probe-net'); + expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-net`, 'bin', 'codegraph'))).toBe(true); + }, 20000); + + it('aborts (exit 1) on a checksum mismatch and caches nothing', async () => { + sumsBody = `${'0'.repeat(64)} ${asset}\n`; + const pkg = makePkg('5.0.0-bad'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--version'], netEnv(cache)); + + expect(r.status).toBe(1); + expect(r.stderr).toContain('checksum mismatch'); + expect(r.stdout).not.toContain('FAKE_BUNDLE_RAN'); // never exec'd a tampered bundle + expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-bad`))).toBe(false); + }, 20000); + + it('proceeds when no SHA256SUMS is published (older releases)', async () => { + sumsBody = null; // 404 + const pkg = makePkg('5.0.0-nosums'); + const cache = mkTmp('cache'); + const r = await runShim(pkg, ['--version'], netEnv(cache)); + + expect(r.status).toBe(0); + expect(r.stderr).toContain('downloading'); + expect(r.stderr).not.toContain('checksum verified'); // skipped, not failed + expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); + }, 20000); +}); diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts new file mode 100644 index 000000000..1722ad2d0 --- /dev/null +++ b/__tests__/object-literal-methods.test.ts @@ -0,0 +1,176 @@ +/** + * Object-literal method extraction (general AST rule). + * + * The extractor pulls function-valued properties out of an object literal that + * is the value of an exported const — either DIRECTLY + * (`export const actions = { foo: () => {} }`) or RETURNED by an initializer + * call (`export const useStore = create((set, get) => ({ foo: () => {} }))`, + * incl. middleware wrappers). This makes store actions (Zustand/Redux/Pinia/ + * MobX/handler maps) real nodes, so `codegraph_node`/`callers` on them resolve + * instead of returning "not found" and forcing the agent to Read the store. + * + * Keyed purely on AST shape — no library names in the implementation — so any + * same-shaped store is covered. Resolution then falls out of the existing + * exact-name matcher: every call form (`const {foo}=useStore.getState(); foo()`, + * `useStore.getState().foo()`, in-store `get().foo()`) reduces to a bare `foo` + * call that resolves to the action node once it exists. + */ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('object-literal method extraction', () => { + it('extracts Zustand store actions (object returned by create()) as function nodes', () => { + const code = ` + import { create } from 'zustand' + interface Store { + count: number + fetchUser(): Promise + switchOrganization(id: string): Promise + reset(): void + } + export const useStore = create((set, get) => ({ + count: 0, + fetchUser: async () => { await get().reset() }, + switchOrganization: async (id: string) => { set({ count: 1 }) }, + reset: () => set({ count: 0 }), + })) + `; + const result = extractFromSource('store.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('fetchUser'); + expect(fnNames).toContain('switchOrganization'); + expect(fnNames).toContain('reset'); + + // Each action's body was walked: fetchUser references its sibling `reset`, + // so an in-store calls edge will resolve once the pipeline runs. + const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!; + const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id); + expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset'); + + // The action's body wasn't mis-attributed to the file scope (the reason we + // skip the generic body-visit for the store-factory call). + const fileNode = result.nodes.find((n) => n.kind === 'file')!; + const fileRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fileNode.id); + expect(fileRefs.map((r) => r.referenceName)).not.toContain('reset'); + }); + + it('extracts actions through a middleware wrapper (create(persist(...)))', () => { + const code = ` + import { create } from 'zustand' + import { persist } from 'zustand/middleware' + export const useCounter = create( + persist( + (set, get) => ({ + value: 0, + increment: () => set({ value: get().value + 1 }), + }), + { name: 'counter' } + ) + ) + `; + const result = extractFromSource('counter.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('increment'); + }); + + it('extracts actions when the initializer returns via a block (=> { return {...} })', () => { + const code = ` + import { create } from 'zustand' + export const useThing = create((set) => { + const initial = 0 + return { + value: initial, + bump: () => set({ value: 1 }), + } + }) + `; + const result = extractFromSource('thing.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('bump'); + }); + + it('does NOT extract methods from a non-exported call-wrapped object (noise gate)', () => { + const code = ` + function wrap(f: any) { return f } + const local = wrap(() => ({ shouldNotExtract: () => {} })) + `; + const result = extractFromSource('inline.ts', code); + const names = result.nodes.map((n) => n.name); + expect(names).not.toContain('shouldNotExtract'); + }); + + it('still extracts the existing direct-object shape (export const actions = {...})', () => { + const code = ` + export const actions = { + load: async () => { helper() }, + } + function helper() {} + `; + const result = extractFromSource('actions.ts', code); + const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); + expect(fnNames).toContain('load'); + }); +}); + +describe('object-literal method resolution (end-to-end)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves callers of store actions across files (destructured + chained getState())', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n'); + fs.writeFileSync( + path.join(tmpDir, 'store.ts'), + `import { create } from 'zustand'\n` + + `interface S { fetchUser(): Promise; reset(): void }\n` + + `export const useStore = create((set, get) => ({\n` + + ` fetchUser: async () => { get().reset() },\n` + + ` reset: () => set({}),\n` + + `}))\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'caller.ts'), + `import { useStore } from './store'\n` + + `export async function loginFlow() {\n` + + ` const { fetchUser } = useStore.getState()\n` + + ` await fetchUser()\n` + + `}\n` + + `export function hardReset() {\n` + + ` useStore.getState().reset()\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const fetchUser = fns.find((n) => n.name === 'fetchUser' && n.filePath.endsWith('store.ts')); + const reset = fns.find((n) => n.name === 'reset' && n.filePath.endsWith('store.ts')); + expect(fetchUser).toBeDefined(); + expect(reset).toBeDefined(); + + // Destructured-then-bare call: loginFlow -> fetchUser + const fetchUserCallers = cg.getCallers(fetchUser!.id).map((c) => c.node.name); + expect(fetchUserCallers).toContain('loginFlow'); + + // Chained getState() call: hardReset -> reset, AND in-store sibling: fetchUser -> reset + const resetCallers = cg.getCallers(reset!.id).map((c) => c.node.name); + expect(resetCallers).toContain('hardReset'); + expect(resetCallers).toContain('fetchUser'); + + cg.close(); + }); +}); diff --git a/__tests__/ppid-watchdog.test.ts b/__tests__/ppid-watchdog.test.ts new file mode 100644 index 000000000..ba270fa7d --- /dev/null +++ b/__tests__/ppid-watchdog.test.ts @@ -0,0 +1,138 @@ +/** + * Unit coverage for the PPID-watchdog decision logic (#277, #692). + * + * The live watchdog timers in `proxy.ts` / `index.ts` are integration-tested on + * POSIX in `mcp-ppid-watchdog.test.ts`, but that test is skipped on Windows + * (`process.kill(pid, 'SIGKILL')` and reparenting are POSIX-specific). That gap + * is exactly how the Windows leak (#692) shipped: on Windows `process.ppid` + * never changes when the parent dies, so the old change-only check could never + * fire. These pure-function tests exercise the Windows branch on any OS by + * stubbing `isAlive` and `platform`. + */ +import { describe, it, expect } from 'vitest'; +import { supervisionLostReason } from '../src/mcp/ppid-watchdog'; + +const alive = () => true; +const dead = () => false; +/** Alive for everyone except the listed pids. */ +const deadOnly = (...pids: number[]) => (pid: number) => !pids.includes(pid); + +describe('supervisionLostReason', () => { + describe('POSIX (parent death reparents → ppid changes)', () => { + it('returns null while the parent is unchanged', () => { + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: null, + isAlive: alive, + platform: 'linux', + }), + ).toBeNull(); + }); + + it('detects a reparent (ppid divergence) as the death signal', () => { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 1, // reparented to init + hostPpid: null, + isAlive: alive, + platform: 'linux', + }); + expect(reason).toBe('ppid 100 -> 1'); + }); + + it('does NOT use liveness on POSIX — a dead original ppid is not orphaning', () => { + // A double-forked grandparent can die while we stay correctly parented. + // POSIX must rely on the change-check only, or it would false-positive. + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: null, + isAlive: dead, + platform: 'linux', + }), + ).toBeNull(); + }); + }); + + describe('Windows (ppid is stable across parent death → poll liveness)', () => { + it('returns null while the original parent is still alive', () => { + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: null, + isAlive: alive, + platform: 'win32', + }), + ).toBeNull(); + }); + + it('detects parent death by liveness even though ppid is unchanged (the #692 fix)', () => { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, // Windows never reparents + hostPpid: null, + isAlive: deadOnly(100), + platform: 'win32', + }); + expect(reason).toBe('parent pid 100 exited'); + }); + + it('ignores pid 0/1 — never a real Windows parent, must not trigger shutdown', () => { + for (const ppid of [0, 1]) { + expect( + supervisionLostReason({ + originalPpid: ppid, + currentPpid: ppid, + hostPpid: null, + isAlive: dead, + platform: 'win32', + }), + ).toBeNull(); + } + }); + }); + + describe('threaded host pid (reached past an intermediate launcher shim)', () => { + it('shuts down when the host pid is gone, on either platform', () => { + for (const platform of ['linux', 'win32'] as const) { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: 42, + isAlive: deadOnly(42), // shim 100 alive, host 42 dead + platform, + }); + expect(reason).toBe('host pid 42 exited'); + } + }); + + it('stays supervised while the host pid is alive', () => { + expect( + supervisionLostReason({ + originalPpid: 100, + currentPpid: 100, + hostPpid: 42, + isAlive: alive, + platform: 'linux', + }), + ).toBeNull(); + }); + }); + + describe('signal precedence', () => { + it('reports the ppid change ahead of a host-gone reason', () => { + const reason = supervisionLostReason({ + originalPpid: 100, + currentPpid: 1, + hostPpid: 42, + isAlive: dead, + platform: 'linux', + }); + expect(reason).toBe('ppid 100 -> 1'); + }); + }); +}); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index d43dceb23..8e8ca8177 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -45,11 +45,11 @@ function cleanupTempDir(dir: string): void { } } -// Check if better-sqlite3 native bindings are available +// Check if the node:sqlite backend is available (Node >= 22.5) function hasSqliteBindings(): boolean { try { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(':memory:'); db.close(); return true; } catch { @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => { describe('Schema v2 Migration', () => { it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(4); + expect(CURRENT_SCHEMA_VERSION).toBe(5); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { @@ -501,10 +501,10 @@ describe('MCP Tool Improvements', () => { expect(typeof ToolHandler).toBe('function'); }); - it.skipIf(!HAS_SQLITE)('should have findSymbol and truncateOutput as private methods', async () => { + it.skipIf(!HAS_SQLITE)('should have findSymbolMatches and truncateOutput as private methods', async () => { const { ToolHandler } = await import('../src/mcp/tools'); const proto = ToolHandler.prototype; - expect(typeof (proto as any).findSymbol).toBe('function'); + expect(typeof (proto as any).findSymbolMatches).toBe('function'); expect(typeof (proto as any).truncateOutput).toBe('function'); }); @@ -567,20 +567,19 @@ export function getValueFromCache(): number { return 2; } await cg.indexAll(); const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); + const findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); - const match = findSymbol(cg, 'getValue'); - expect(match).not.toBeNull(); - expect(match.node.name).toBe('getValue'); - // Should not have a disambiguation note for single exact match - expect(match.note).toBe(''); + const matches = findSymbolMatches(cg, 'getValue'); + // Exact-name match wins — a single result, not the partial getValueFromCache. + expect(matches.length).toBe(1); + expect(matches[0].name).toBe('getValue'); handler.closeAll(); cg.destroy(); cleanupTempDir(tmpDir); }); - it.skipIf(!HAS_SQLITE)('should note when multiple symbols share the same name', async () => { + it.skipIf(!HAS_SQLITE)('should return all definitions when multiple symbols share the same name', async () => { const { ToolHandler } = await import('../src/mcp/tools'); const CodeGraph = (await import('../src/index')).default; @@ -602,20 +601,21 @@ export function handle(): void {} await cg.indexAll(); const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); + const findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); - const match = findSymbol(cg, 'handle'); - expect(match).not.toBeNull(); - expect(match.node.name).toBe('handle'); - // Should have a disambiguation note - expect(match.note).toContain('2 symbols named "handle"'); + // Both same-named definitions are returned (no longer one + a dead-end + // note) so codegraph_node can hand back every overload and the agent never + // Reads to find the one it wanted. + const matches = findSymbolMatches(cg, 'handle'); + expect(matches.length).toBe(2); + expect(matches.every((n: any) => n.name === 'handle')).toBe(true); handler.closeAll(); cg.destroy(); cleanupTempDir(tmpDir); }); - it.skipIf(!HAS_SQLITE)('should return null when symbol is not found', async () => { + it.skipIf(!HAS_SQLITE)('should return no matches when symbol is not found', async () => { const { ToolHandler } = await import('../src/mcp/tools'); const CodeGraph = (await import('../src/index')).default; @@ -630,10 +630,10 @@ export function handle(): void {} await cg.indexAll(); const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); + const findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); - const match = findSymbol(cg, 'nonExistentSymbol'); - expect(match).toBeNull(); + const matches = findSymbolMatches(cg, 'nonExistentSymbol'); + expect(matches.length).toBe(0); handler.closeAll(); cg.destroy(); diff --git a/__tests__/prepare-release.test.ts b/__tests__/prepare-release.test.ts new file mode 100644 index 000000000..1491f8897 --- /dev/null +++ b/__tests__/prepare-release.test.ts @@ -0,0 +1,221 @@ +/** + * Unit tests for `scripts/prepare-release.mjs`. + * + * The script reads CHANGELOG.md and package.json from `process.cwd()`, + * so the tests run it via `node` in a temp directory after staging + * those files. Real script, real fs — keeps the test honest about what + * the workflow will actually do. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'prepare-release.mjs'); + +function run(cwd: string, ...args: string[]) { + const out = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }); + return out.trim(); +} + +function setup(changelog: string, version = '1.2.3') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-release-')); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version })); + return dir; +} + +const HEADER = `# Changelog + +Some intro. + +`; + +describe('prepare-release.mjs', () => { + let dir: string; + afterEach(() => { + if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe('Case A: [version] block does not yet exist', () => { + it('renames [Unreleased] to [version] - and adds a fresh empty [Unreleased]', () => { + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- New feature foo\n- New feature bar\n\n### Fixed\n- Fixed thing\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old entry\n`, + ); + const out = run(dir); + expect(out).toMatch(/renamed \[Unreleased\] to \[1\.2\.3\]/); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + // [Unreleased] is now empty and at the top. + expect(result).toMatch(/## \[Unreleased\]\n\n\n## \[1\.2\.3\]/); + // [1.2.3] gets a date. + expect(result).toMatch(/## \[1\.2\.3\] - \d{4}-\d{2}-\d{2}/); + // Promoted content lives under [1.2.3]. + const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0]; + expect(v123Section).toContain('### Added'); + expect(v123Section).toContain('- New feature foo'); + expect(v123Section).toContain('- New feature bar'); + expect(v123Section).toContain('### Fixed'); + expect(v123Section).toContain('- Fixed thing'); + // [1.2.2] is intact. + expect(result).toContain('## [1.2.2] - 2026-01-01'); + expect(result).toContain('- Old entry'); + }); + }); + + describe('Case B: [version] already exists AND [Unreleased] has content', () => { + it('merges Unreleased sub-sections into the matching [version] sub-sections', () => { + // The v0.9.5 scenario verbatim: sparse [0.9.5] with two Fixed + // entries, full [Unreleased] above it with Added + more Fixed. + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- Big feature 1\n- Big feature 2\n\n### Fixed\n- Watcher fix\n- Worktree fix\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix A\n- Old fix B\n\n## [1.2.2] - 2026-01-01\n`, + ); + const out = run(dir); + expect(out).toMatch(/merged \d+ Unreleased entries/); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + // [Unreleased] is emptied. + const unrelSection = result.split('## [Unreleased]')[1].split('## [1.2.3]')[0]; + expect(unrelSection.trim()).toBe(''); + + // [1.2.3] now has BOTH the original Fixed entries AND the + // Unreleased Fixed entries, plus the new Added sub-section. + const v123Section = result.split('## [1.2.3]')[1].split('## [1.2.2]')[0]; + expect(v123Section).toContain('### Added'); + expect(v123Section).toContain('- Big feature 1'); + expect(v123Section).toContain('- Big feature 2'); + expect(v123Section).toContain('### Fixed'); + expect(v123Section).toContain('- Old fix A'); + expect(v123Section).toContain('- Old fix B'); + expect(v123Section).toContain('- Watcher fix'); + expect(v123Section).toContain('- Worktree fix'); + // Date on [1.2.3] is preserved (we don't re-stamp it). + expect(result).toContain('## [1.2.3] - 2026-02-02'); + }); + + it('appends sub-sections that exist only in [Unreleased] to the [version] block', () => { + dir = setup( + HEADER + + `## [Unreleased]\n\n### Security\n- CVE patch\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- Old fix\n`, + ); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const v123 = result.split('## [1.2.3]')[1]; + expect(v123).toContain('### Fixed'); + expect(v123).toContain('- Old fix'); + expect(v123).toContain('### Security'); + expect(v123).toContain('- CVE patch'); + }); + }); + + describe('Case C: [Unreleased] has no entries', () => { + it('is a no-op when [Unreleased] is empty', () => { + dir = setup(HEADER + `## [Unreleased]\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- thing\n`); + const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const out = run(dir); + expect(out).toMatch(/nothing to do/); + const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(after).toBe(before); + }); + + it('is a no-op when [Unreleased] has only sub-section headings with no bullets', () => { + dir = setup( + HEADER + `## [Unreleased]\n\n### Added\n\n### Fixed\n\n## [1.2.3] - 2026-02-02\n`, + ); + const before = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const out = run(dir); + expect(out).toMatch(/nothing to do/); + const after = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(after).toBe(before); + }); + }); + + describe('idempotency', () => { + it('running twice produces the same output as running once', () => { + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- Thing A\n\n## [1.2.2] - 2026-01-01\n\n### Added\n- Old\n`, + ); + run(dir); // first run promotes + const afterFirst = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const out2 = run(dir); // second run should be a no-op + const afterSecond = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(out2).toMatch(/nothing to do/); + expect(afterSecond).toBe(afterFirst); + }); + }); + + describe('version source', () => { + it('reads the target version from package.json by default', () => { + dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9'); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(result).toContain('## [9.9.9]'); + }); + + it('accepts an explicit version argument that overrides package.json', () => { + dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n`, '9.9.9'); + run(dir, '5.5.5'); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(result).toContain('## [5.5.5]'); + expect(result).not.toContain('## [9.9.9]'); + }); + }); + + describe('link reference', () => { + it('appends a `[version]: https://...` link reference at EOF when promoting (Case A)', () => { + dir = setup(HEADER + `## [Unreleased]\n\n### Added\n- x\n\n## [1.2.2] - 2026-01-01\n`); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(result).toContain( + '[1.2.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.3', + ); + }); + + it('appends a link reference when merging into an existing [version] (Case B)', () => { + dir = setup( + HEADER + `## [Unreleased]\n\n### Added\n- new\n\n## [1.2.3] - 2026-02-02\n\n### Fixed\n- prior\n`, + ); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + expect(result).toContain( + '[1.2.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.3', + ); + }); + + it('does not double-add an existing link reference', () => { + const ref = '[1.2.3]: https://github.com/colbymchenry/codegraph/releases/tag/v1.2.3'; + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- x\n\n## [1.2.2] - 2026-01-01\n\n${ref}\n`, + ); + run(dir); + const result = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const occurrences = result.split(ref).length - 1; + expect(occurrences).toBe(1); + }); + }); + + describe('extractor integration', () => { + it('the resulting [version] block is what extract-release-notes.mjs would surface', () => { + // Run prepare, then extract — confirm the output contains all the + // promoted entries. + dir = setup( + HEADER + + `## [Unreleased]\n\n### Added\n- Feature A\n- Feature B\n\n### Fixed\n- Bug fix\n\n## [1.2.2] - 2026-01-01\n`, + ); + run(dir); + + const extractor = path.resolve(__dirname, '..', 'scripts', 'extract-release-notes.mjs'); + const notes = execFileSync('node', [extractor, '1.2.3'], { cwd: dir, encoding: 'utf8' }); + expect(notes).toContain('### Added'); + expect(notes).toContain('Feature A'); + expect(notes).toContain('Feature B'); + expect(notes).toContain('### Fixed'); + expect(notes).toContain('Bug fix'); + }); + }); +}); diff --git a/__tests__/react-native-bridge.test.ts b/__tests__/react-native-bridge.test.ts new file mode 100644 index 000000000..dec3ce5cb --- /dev/null +++ b/__tests__/react-native-bridge.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Node, Language } from '../src/types'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import { reactNativeBridgeResolver } from '../src/resolution/frameworks/react-native'; +import { CodeGraph } from '../src'; + +/** + * Mock ResolutionContext for the React Native bridge resolver. + */ +function makeContext(nodes: Node[], fileContents: Record = {}): ResolutionContext { + const byName = new Map(); + for (const n of nodes) { + const arr = byName.get(n.name); + if (arr) arr.push(n); + else byName.set(n.name, [n]); + } + // Files = union of node files + any extra fileContents keys (for files that + // have content like .mm bridge declarations but no extracted nodes yet). + const allFiles = new Set( + [...nodes.map((n) => n.filePath), ...Object.keys(fileContents)] + ); + return { + getNodesInFile: (fp) => nodes.filter((n) => n.filePath === fp), + getNodesByName: (name) => byName.get(name) ?? [], + getNodesByQualifiedName: () => { throw new Error('not used'); }, + getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind), + getNodesByLowerName: () => { throw new Error('not used'); }, + fileExists: (fp) => allFiles.has(fp), + readFile: (fp) => fileContents[fp] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => Array.from(allFiles), + getImportMappings: () => [], + }; +} + +function method( + name: string, + language: Language, + filePath: string, + startLine = 10 +): Node { + return { + id: `${language}:${filePath}:${name}:${startLine}`, + kind: 'method', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language, + startLine, + endLine: startLine + 5, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + } as Node; +} + +function ref(name: string, language: Language, filePath: string): UnresolvedRef { + return { + fromNodeId: `caller:${filePath}`, + referenceName: name, + referenceKind: 'calls', + line: 1, + column: 0, + filePath, + language, + }; +} + +describe('React Native bridge resolver', () => { + describe('detect()', () => { + it('returns true when package.json declares react-native', () => { + const ctx = makeContext([], { + 'package.json': + '{"name":"x","dependencies":{"react-native":"^0.73.0"}}', + }); + expect(reactNativeBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns true when an ObjC file uses RCT_EXPORT_MODULE', () => { + const ctx = makeContext([], { + 'NativeFoo.mm': '@implementation Foo\nRCT_EXPORT_MODULE()\n@end', + }); + expect(reactNativeBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns true when a TS file uses TurboModuleRegistry', () => { + const ctx = makeContext([], { + 'NativeFoo.ts': + "import { TurboModuleRegistry } from 'react-native';\n" + + "export default TurboModuleRegistry.getEnforcing('Foo');", + }); + expect(reactNativeBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns false when none of the RN signals are present', () => { + const ctx = makeContext([method('hi', 'objc', 'X.m')]); + expect(reactNativeBridgeResolver.detect(ctx)).toBe(false); + }); + }); + + describe('legacy bridge — ObjC side', () => { + it('resolves JS callsite via RCT_EXPORT_METHOD with default module name', () => { + // RCTGeolocation → module name 'Geolocation' (RCT prefix stripped). + const native = method('getCurrentPosition:', 'objc', 'RCTGeolocation.m'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'RCTGeolocation.m': + '@implementation RCTGeolocation\n' + + 'RCT_EXPORT_MODULE()\n' + + 'RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) {}\n' + + '@end', + }); + const result = reactNativeBridgeResolver.resolve( + ref('getCurrentPosition', 'javascript', 'App.js'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('resolves via explicit module name in RCT_EXPORT_MODULE(name)', () => { + const native = method('startScan:', 'objc', 'Bluetooth.m'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'Bluetooth.m': + '@implementation BluetoothImpl\n' + + 'RCT_EXPORT_MODULE(BluetoothManager)\n' + + 'RCT_EXPORT_METHOD(startScan:(RCTResponseSenderBlock)cb) {}\n' + + '@end', + }); + const result = reactNativeBridgeResolver.resolve( + ref('startScan', 'javascript', 'App.js'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + }); + + it('resolves RCT_REMAP_METHOD with JS-name override', () => { + const native = method('doInternalCompute:', 'objc', 'Computer.m'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'Computer.m': + '@implementation Computer\n' + + 'RCT_EXPORT_MODULE()\n' + + 'RCT_REMAP_METHOD(compute, doInternalCompute:(NSDictionary *)opts) {}\n' + + '@end', + }); + const result = reactNativeBridgeResolver.resolve( + ref('compute', 'javascript', 'App.js'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + }); + }); + + describe('legacy bridge — Java side', () => { + it('resolves @ReactMethod with getName() literal', () => { + const native = method('getCurrentPosition', 'java', 'GeolocationModule.java'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'GeolocationModule.java': + 'class GeolocationModule extends ReactContextBaseJavaModule {\n' + + ' @Override public String getName() { return "Geolocation"; }\n' + + ' @ReactMethod public void getCurrentPosition(Callback cb) {}\n' + + '}', + }); + const result = reactNativeBridgeResolver.resolve( + ref('getCurrentPosition', 'javascript', 'App.js'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + }); + + it('resolves Kotlin @ReactMethod fun', () => { + const native = method('startScan', 'kotlin', 'BluetoothModule.kt'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'BluetoothModule.kt': + 'class BluetoothModule(ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {\n' + + ' override fun getName(): String = "BluetoothManager"\n' + + ' @ReactMethod fun startScan(cb: Callback) {}\n' + + '}', + }); + const result = reactNativeBridgeResolver.resolve( + ref('startScan', 'javascript', 'App.js'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + }); + }); + + describe('TurboModule spec resolution', () => { + it('matches spec method to native ObjC implementation by name', () => { + // The Spec interface lists `getTotalLength`; ObjC has a method by the + // same first keyword. Bridge matches by name. + const native = method('getTotalLength:', 'objc', 'RNSVGRenderableManager.mm'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'NativeSvgRenderableModule.ts': + "import { TurboModuleRegistry } from 'react-native';\n" + + 'export interface Spec extends TurboModule {\n' + + ' getTotalLength(tag: number): number;\n' + + ' isPointInFill(tag: number, options?: object): boolean;\n' + + '}\n' + + "export default TurboModuleRegistry.getEnforcing('RNSVGRenderableModule');", + }); + const result = reactNativeBridgeResolver.resolve( + ref('getTotalLength', 'tsx', 'SvgComponent.tsx'), + ctx + ); + expect(result?.targetNodeId).toBe(native.id); + }); + + it('returns null when spec method has no matching native impl', () => { + const ctx = makeContext([], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'NativeFoo.ts': + "import { TurboModuleRegistry } from 'react-native';\n" + + 'export interface Spec extends TurboModule {\n' + + ' thingThatDoesntExist(): void;\n' + + '}\n' + + "export default TurboModuleRegistry.getEnforcing('Foo');", + }); + const result = reactNativeBridgeResolver.resolve( + ref('thingThatDoesntExist', 'tsx', 'Caller.tsx'), + ctx + ); + expect(result).toBeNull(); + }); + }); + + describe('qualified vs bare callsite names', () => { + it('handles bare method name (post receiver-strip)', () => { + const native = method('compute:', 'objc', 'Mod.m'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'Mod.m': + '@implementation Mod\nRCT_EXPORT_MODULE()\nRCT_EXPORT_METHOD(compute:(NSDictionary *)x) {}\n@end', + }); + expect( + reactNativeBridgeResolver.resolve(ref('compute', 'javascript', 'App.js'), ctx) + ).not.toBeNull(); + }); + + it('strips dot prefix on receiver-qualified callsite (NativeModules.Mod.compute → compute)', () => { + const native = method('compute:', 'objc', 'Mod.m'); + const ctx = makeContext([native], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'Mod.m': + '@implementation Mod\nRCT_EXPORT_MODULE()\nRCT_EXPORT_METHOD(compute:(NSDictionary *)x) {}\n@end', + }); + expect( + reactNativeBridgeResolver.resolve( + ref('NativeModules.Mod.compute', 'javascript', 'App.js'), + ctx + ) + ).not.toBeNull(); + }); + }); + + it('does not resolve native-language callers (resolver is JS-side only)', () => { + const native = method('compute:', 'objc', 'Mod.m'); + const ctx = makeContext([native]); + expect( + reactNativeBridgeResolver.resolve(ref('compute', 'objc', 'OtherMod.m'), ctx) + ).toBeNull(); + }); + + describe('RCTEventEmitter built-ins blocklist', () => { + it('skips addListener / remove (every emitter exposes these — bridging them creates noise)', () => { + // A repo with RCTEventEmitter subclass: defines `addListener:` and + // `remove:` because that's what `[RCTEventEmitter addListener:]` + // requires. JS callers of `.addListener(...)` should NOT resolve + // here — they're hitting the JS-side `NativeEventEmitter` + // abstraction, not the native emitter directly. + const native1 = method('addListener:', 'objc', 'EventEmitter.m'); + const native2 = method('remove:', 'objc', 'EventEmitter.m'); + const ctx = makeContext([native1, native2], { + 'package.json': '{"dependencies":{"react-native":"^0.73"}}', + 'EventEmitter.m': + '@implementation EventEmitter\n' + + 'RCT_EXPORT_MODULE()\n' + + 'RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {}\n' + + 'RCT_EXPORT_METHOD(remove:(double)id) {}\n' + + '@end', + }); + expect( + reactNativeBridgeResolver.resolve(ref('addListener', 'javascript', 'App.js'), ctx) + ).toBeNull(); + expect( + reactNativeBridgeResolver.resolve(ref('remove', 'typescript', 'App.ts'), ctx) + ).toBeNull(); + }); + }); +}); + +describe('React Native cross-platform pairing — end to end', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-xplat-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('links the Android (@ReactMethod) and iOS (RCT_EXPORT_METHOD) impls of a JS-called method', async () => { + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}'); + fs.writeFileSync(path.join(dir, 'index.ts'), + "import { NativeModules } from 'react-native';\n" + + "export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n"); + fs.writeFileSync(path.join(dir, 'RNThing.java'), + "public class RNThing extends ReactContextBaseJavaModule {\n" + + " @Override public String getName() { return \"RNThing\"; }\n" + + " @ReactMethod public void uniquePingMethod(Callback cb) {}\n}\n"); + fs.writeFileSync(path.join(dir, 'RNThing.m'), + "@implementation RNThing\n" + + "RCT_EXPORT_MODULE()\n" + + "RCT_EXPORT_METHOD(uniquePingMethod:(RCTResponseSenderBlock)cb) {}\n@end\n"); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // The iOS `RCT_EXPORT_METHOD` is extracted as an ObjC method node (the macro + // parses as a macro-expression, not a method, so it had no node before). + const objc = db.prepare( + "SELECT * FROM nodes WHERE name='uniquePingMethod' AND language='objc' AND id LIKE 'rn-export:%'" + ).all(); + expect(objc).toHaveLength(1); + + // The Java and ObjC impls of `uniquePingMethod` are linked to each other, so + // a JS call that resolves to one platform reaches the other. + const pair = db.prepare( + `SELECT count(*) c FROM edges e + JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target + WHERE json_extract(e.metadata,'$.synthesizedBy')='rn-cross-platform' + AND s.name LIKE 'uniquePingMethod%' AND t.name LIKE 'uniquePingMethod%' + AND s.language != t.language` + ).get(); + cg.close?.(); + expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 1ca3a3f82..3059392d4 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -12,7 +12,8 @@ import { CodeGraph } from '../src'; import { Node, UnresolvedReference } from '../src/types'; import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; import { matchReference } from '../src/resolution/name-matcher'; -import { resolveImportPath, extractImportMappings } from '../src/resolution/import-resolver'; +import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver'; +import type { UnresolvedRef } from '../src/resolution/types'; import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; import { QueryBuilder } from '../src/db/queries'; import { DatabaseConnection } from '../src/db'; @@ -353,6 +354,116 @@ from ..services import auth_service }); }); + describe('JVM FQN Import Resolution', () => { + // Build a ResolutionContext stub whose getNodesByQualifiedName answers + // from a fixed table — the only context method resolveJvmImport touches. + const makeContext = (byQName: Record): ResolutionContext => ({ + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: (q) => byQName[q] ?? [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => [], + }); + const node = (id: string, name: string, qualifiedName: string, kind: Node['kind'] = 'class', language: Node['language'] = 'kotlin'): Node => ({ + id, kind, name, qualifiedName, + filePath: 'Models.kt', language, + startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, + updatedAt: 0, + }); + const importRef = (referenceName: string, language: Node['language'] = 'kotlin'): UnresolvedRef => ({ + fromNodeId: 'caller', + referenceName, + referenceKind: 'imports', + line: 1, column: 0, + filePath: 'Caller.kt', + language, + }); + + it('resolves a Kotlin class import by FQN regardless of filename', () => { + const target = node('n1', 'Bar', 'com.example.foo::Bar'); + const ctx = makeContext({ 'com.example.foo::Bar': [target] }); + const result = resolveJvmImport(importRef('com.example.foo.Bar'), ctx); + expect(result?.targetNodeId).toBe('n1'); + expect(result?.resolvedBy).toBe('import'); + }); + + it('resolves a Kotlin top-level function import by FQN', () => { + const util = node('n2', 'util', 'com.example.foo::util', 'function'); + const ctx = makeContext({ 'com.example.foo::util': [util] }); + const result = resolveJvmImport(importRef('com.example.foo.util'), ctx); + expect(result?.targetNodeId).toBe('n2'); + }); + + it('resolves a Java import by FQN', () => { + const target = node('n3', 'Bar', 'com.example.foo::Bar', 'class', 'java'); + const ctx = makeContext({ 'com.example.foo::Bar': [target] }); + const result = resolveJvmImport(importRef('com.example.foo.Bar', 'java'), ctx); + expect(result?.targetNodeId).toBe('n3'); + }); + + it('resolves cross-language: Kotlin importing a Java class', () => { + // The Kotlin file declares `import com.example.JavaBar` — the target is + // a Java class node. JVM interop means the resolver doesn't care about + // the source language of the target, only that the FQN matches. + const target = node('n4', 'JavaBar', 'com.example::JavaBar', 'class', 'java'); + const ctx = makeContext({ 'com.example::JavaBar': [target] }); + const result = resolveJvmImport(importRef('com.example.JavaBar'), ctx); + expect(result?.targetNodeId).toBe('n4'); + }); + + it('disambiguates a name collision across packages', () => { + // Two classes named `Bar` in different packages. Each import resolves + // to the one whose FQN matches — not to "whichever was found first". + const barA = node('n5a', 'Bar', 'com.example.alpha::Bar'); + const barB = node('n5b', 'Bar', 'com.example.beta::Bar'); + const ctx = makeContext({ + 'com.example.alpha::Bar': [barA], + 'com.example.beta::Bar': [barB], + }); + expect(resolveJvmImport(importRef('com.example.alpha.Bar'), ctx)?.targetNodeId).toBe('n5a'); + expect(resolveJvmImport(importRef('com.example.beta.Bar'), ctx)?.targetNodeId).toBe('n5b'); + }); + + it('returns null for wildcard imports', () => { + const ctx = makeContext({}); + expect(resolveJvmImport(importRef('com.example.foo.*'), ctx)).toBeNull(); + }); + + it('returns null for unqualified names', () => { + // A single-segment name has no package; nothing to look up by FQN. + const ctx = makeContext({ 'Bar': [node('n6', 'Bar', 'Bar')] }); + expect(resolveJvmImport(importRef('Bar'), ctx)).toBeNull(); + }); + + it('returns null for non-JVM languages', () => { + const target = node('n7', 'Bar', 'com.example::Bar'); + const ctx = makeContext({ 'com.example::Bar': [target] }); + expect(resolveJvmImport(importRef('com.example.Bar', 'typescript'), ctx)).toBeNull(); + }); + + it('returns null for non-imports reference kinds', () => { + // The resolver intentionally only acts on `imports` refs; ordinary + // `calls`/`extends` refs fall through to the framework + name-matcher + // strategies. + const target = node('n8', 'Bar', 'com.example::Bar'); + const ctx = makeContext({ 'com.example::Bar': [target] }); + const ref: UnresolvedRef = { + fromNodeId: 'caller', referenceName: 'com.example.Bar', + referenceKind: 'calls', line: 1, column: 0, + filePath: 'Caller.kt', language: 'kotlin', + }; + expect(resolveJvmImport(ref, ctx)).toBeNull(); + }); + + it('returns null when the FQN is not in the index', () => { + const ctx = makeContext({}); + expect(resolveJvmImport(importRef('com.example.Unknown'), ctx)).toBeNull(); + }); + }); + describe('Framework Detection', () => { it('should detect React framework', () => { const context: ResolutionContext = { @@ -470,12 +581,23 @@ from ..services import auth_service line: 10, column: 5, filePath: 'src/App.tsx', - language: 'typescript' as const, + // Refs extracted from .tsx files carry language 'tsx' — component + // resolution is gated to JSX-capable refs (#764: PascalCase TYPE refs + // from plain .ts files were resolving to arbitrary same-named classes). + language: 'tsx' as const, }; const result = reactResolver!.resolve(ref, context); expect(result).not.toBeNull(); expect(result?.targetNodeId).toBe('component:src/Button.tsx:Button:5'); + + // The same PascalCase name referenced from a plain .ts file is a TYPE + // reference, not a component usage — component resolution must decline + // and leave it to proximity-aware name matching (#764: a .ts GraphQL + // types file's own `Account` alias was losing to an arbitrary same-named + // class in another monorepo package). + const tsRef = { ...ref, filePath: 'src/models.ts', language: 'typescript' as const }; + expect(reactResolver!.resolve(tsRef, context)).toBeNull(); }); it('should resolve custom hook references', () => { @@ -645,6 +767,441 @@ def bootstrap(): ); expect(callsToUserService).toHaveLength(0); }); + + it('resolves Go cross-package qualified calls via go.mod module path (#388)', async () => { + // Pre-#388, every `pkga.FuncX(...)` call in a Go monorepo was flagged + // external (isExternalImport returned true for any non-`/internal/` + // import without `.`-prefix) and resolution fell through to name-match + // with path proximity — recall on cross-package callers was ~<1%. + fs.writeFileSync( + path.join(tempDir, 'go.mod'), + 'module github.com/example/myproject\n\ngo 1.21\n' + ); + + const pkgaDir = path.join(tempDir, 'pkga'); + const pkgbDir = path.join(tempDir, 'pkgb'); + const pkgcDir = path.join(tempDir, 'pkgc'); + fs.mkdirSync(pkgaDir); + fs.mkdirSync(pkgbDir); + fs.mkdirSync(pkgcDir); + + // Same-name exported function in two packages — only the imported one + // should resolve. Exercises disambiguation, not just connectivity. + fs.writeFileSync( + path.join(pkgaDir, 'conv.go'), + 'package pkga\nfunc Convert(x int) int { return x * 2 }\n' + ); + fs.writeFileSync( + path.join(pkgbDir, 'conv.go'), + 'package pkgb\nfunc Convert(x int) int { return x + 1 }\n' + ); + fs.writeFileSync( + path.join(pkgcDir, 'use.go'), + `package pkgc + +import "github.com/example/myproject/pkga" + +func UsePkga() { + pkga.Convert(5) +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const usePkga = cg.getNodesByKind('function').filter((n) => n.name ==='UsePkga')[0]; + expect(usePkga).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(usePkga!.id); + const callEdges = outgoing.filter((e) => e.kind === 'calls'); + expect(callEdges).toHaveLength(1); + + const target = cg.getNode(callEdges[0]!.target); + expect(target?.name).toBe('Convert'); + // Critical: the resolver must pick the imported pkga's Convert, + // not pkgb's. With the broken (pre-fix) resolver this lands on + // whichever Convert happens to be cheaper under path proximity. + expect(target?.filePath.replace(/\\/g, '/')).toBe('pkga/conv.go'); + }); + + it('resolves Go aliased imports across packages (#388)', async () => { + fs.writeFileSync( + path.join(tempDir, 'go.mod'), + 'module github.com/example/myproject\n\ngo 1.21\n' + ); + fs.mkdirSync(path.join(tempDir, 'pkgb')); + fs.mkdirSync(path.join(tempDir, 'pkgd')); + + fs.writeFileSync( + path.join(tempDir, 'pkgb', 'lib.go'), + 'package pkgb\nfunc Compute(x int) int { return x }\n' + ); + fs.writeFileSync( + path.join(tempDir, 'pkgd', 'use.go'), + `package pkgd + +import ( + "fmt" + alias "github.com/example/myproject/pkgb" +) + +func UseAliased() { + fmt.Println("hi") + alias.Compute(3) +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const useAliased = cg.getNodesByKind('function').filter((n) => n.name ==='UseAliased')[0]; + expect(useAliased).toBeDefined(); + const calls = cg.getOutgoingEdges(useAliased!.id).filter((e) => e.kind === 'calls'); + // fmt.Println is stdlib — must stay external. alias.Compute must resolve. + expect(calls).toHaveLength(1); + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('Compute'); + expect(target?.filePath.replace(/\\/g, '/')).toBe('pkgb/lib.go'); + }); + + it('resolves Python module-attribute calls after `from pkg import module` (#578)', async () => { + // Pre-#578, a `module.func()` call where `module` was bound via + // `from pkg import module` dropped its `calls` edge. The file→file import + // edge resolved (resolveModuleImportToFile falls back to a dotted-module + // file lookup for absolute package paths), but resolvePythonModuleMember + // had no such fallback — resolveImportPath returns null for an absolute + // package path like `pkg.module`, so the member never resolved and + // callers/callees/impact on the target came back empty. Same root-cause + // class as the Go cross-package qualified call (#388). + fs.mkdirSync(path.join(tempDir, 'pkg')); + fs.writeFileSync(path.join(tempDir, 'pkg', '__init__.py'), ''); + fs.writeFileSync( + path.join(tempDir, 'pkg', 'module.py'), + 'def func():\n return 1\n' + ); + fs.writeFileSync( + path.join(tempDir, 'main.py'), + `from pkg import module +import os + + +def caller(): + return module.func() + + +def external_caller(): + return os.getcwd() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const caller = cg.getNodesByKind('function').filter((n) => n.name === 'caller')[0]; + expect(caller).toBeDefined(); + const calls = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'); + // module.func() must resolve to the real function in the submodule file. + expect(calls).toHaveLength(1); + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('func'); + expect(target?.filePath.replace(/\\/g, '/')).toBe('pkg/module.py'); + + // The flip side of the fix: an attribute call through a *stdlib* module + // (`os.getcwd()`) must still create no edge — the fallback only matches + // real in-repo module files. + const externalCaller = cg.getNodesByKind('function').filter((n) => n.name === 'external_caller')[0]; + expect(externalCaller).toBeDefined(); + const externalCalls = cg.getOutgoingEdges(externalCaller!.id).filter((e) => e.kind === 'calls'); + expect(externalCalls).toHaveLength(0); + }); + + it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => { + // In Go a type's methods are commonly declared in a different file from the + // `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in + // box_methods.go). Extraction only attaches the struct→method `contains` + // edge when the type is in the SAME file (the owner lookup is file-scoped), + // so a cross-file method was orphaned from its struct — breaking member + // outlines and any callers/callees/impact traversal through `contains`. A + // resolution-phase pass now links them within the package (= directory). + fs.writeFileSync( + path.join(tempDir, 'box.go'), + 'package main\n\ntype Box struct{ v int }\n' + ); + fs.writeFileSync( + path.join(tempDir, 'box_methods.go'), + 'package main\n\nfunc (b *Box) Get() int { return b.v }\nfunc (b *Box) Set(x int) { b.v = x }\n' + ); + // Generic receiver declared cross-file too — exercises #583 half A + // (generic `*Stack[T]` receiver parsing) and half B (cross-file) together. + fs.writeFileSync( + path.join(tempDir, 'stack.go'), + 'package main\n\ntype Stack[T any] struct {\n\titems []T\n}\n' + ); + fs.writeFileSync( + path.join(tempDir, 'stack_push.go'), + 'package main\n\nfunc (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }\n' + ); + // A same-named type in another package must NOT capture this package's + // methods — the link is scoped to the receiver type's own directory. + fs.mkdirSync(path.join(tempDir, 'other')); + fs.writeFileSync( + path.join(tempDir, 'other', 'box.go'), + 'package other\n\ntype Box struct{ w int }\n' + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const methodsOf = (typeName: string, file: string): string[] => { + const node = cg + .getNodesByKind('struct') + .find((n) => n.name === typeName && n.filePath.replace(/\\/g, '/') === file); + expect(node, `${typeName} @ ${file}`).toBeDefined(); + return cg + .getOutgoingEdges(node!.id) + .filter((e) => e.kind === 'contains') + .map((e) => cg.getNode(e.target)) + .filter((n) => !!n && n.kind === 'method') + .map((n) => n!.name) + .sort(); + }; + + // Cross-file (non-generic) methods now attach to their struct. + expect(methodsOf('Box', 'box.go')).toEqual(['Get', 'Set']); + // Generic + cross-file. + expect(methodsOf('Stack', 'stack.go')).toEqual(['Push']); + // Cross-package isolation: other/Box defines no methods of its own. + expect(methodsOf('Box', 'other/box.go')).toEqual([]); + }); + + it('TS type_alias object-shape members resolve method calls (#359)', async () => { + // Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached + // to `StdioMcpClient.stop` in a sibling directory via path-proximity + // because the type_alias had no `stop` node — only the unrelated + // class did. Now type_alias produces member nodes (property/method), + // so the camelCase receiver↔type word overlap pulls the call to + // `RecorderHandle::stop` instead of the look-alike class. + fs.mkdirSync(path.join(tempDir, 'voice')); + fs.mkdirSync(path.join(tempDir, 'codegraph')); + + fs.writeFileSync( + path.join(tempDir, 'voice', 'recorder.ts'), + `export type RecorderHandle = { + wavPath: string; + stop: () => Promise<{ ok: true }>; +}; +` + ); + fs.writeFileSync( + path.join(tempDir, 'voice', 'controller.ts'), + `import type { RecorderHandle } from "./recorder"; +export async function finaliseRecording(recorder: RecorderHandle) { + return await recorder.stop(); +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'codegraph', 'stdio-client.ts'), + `export class StdioMcpClient { + private stopped = false; + async stop(): Promise { this.stopped = true; } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const handleStop = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'RecorderHandle::stop'); + expect(handleStop).toBeDefined(); + + const clientStop = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'StdioMcpClient::stop'); + expect(clientStop).toBeDefined(); + + const handleCallers = cg.getIncomingEdges(handleStop!.id).filter((e) => e.kind === 'calls'); + const clientCallers = cg.getIncomingEdges(clientStop!.id).filter((e) => e.kind === 'calls'); + expect(handleCallers.length).toBeGreaterThanOrEqual(1); + // The class method must have NO callers — voice/'s call must NOT + // mis-attribute. A non-empty list would mean the false-positive + // path is still firing. + expect(clientCallers).toHaveLength(0); + + // Function-typed property surfaces as a `method` node, not `property`, + // because `stop()` semantics at the call site are method semantics. + expect(handleStop!.kind).toBe('method'); + }); + + it('Java import disambiguates same-name classes across modules (#314)', async () => { + // Pre-#314 the import resolver had no Java branch at all, so a + // multi-module Maven repo where `dao/converter/FooConverter` and + // `service/converter/FooConverter` both export a `convert` method + // resolved by file-path proximity — picking whichever class was + // closer to the caller, which is wrong any time the caller lives + // in an equidistant cross-cutting module. + const daoDir = path.join(tempDir, 'dao/src/main/java/com/example/dao/converter'); + const serviceDir = path.join(tempDir, 'service/src/main/java/com/example/service/converter'); + const webDir = path.join(tempDir, 'web/src/main/java/com/example/web'); + fs.mkdirSync(daoDir, { recursive: true }); + fs.mkdirSync(serviceDir, { recursive: true }); + fs.mkdirSync(webDir, { recursive: true }); + + fs.writeFileSync( + path.join(daoDir, 'FooConverter.java'), + `package com.example.dao.converter; +public class FooConverter { public String convert(String x) { return "dao:" + x; } } +` + ); + fs.writeFileSync( + path.join(serviceDir, 'FooConverter.java'), + `package com.example.service.converter; +public class FooConverter { public String convert(String x) { return "svc:" + x; } } +` + ); + // The caller imports the SERVICE version — even though dao is + // alphabetically/lexically first in the candidate list, the + // import must trump that order. + fs.writeFileSync( + path.join(webDir, 'Handler.java'), + `package com.example.web; + +import com.example.service.converter.FooConverter; + +public class Handler { + private FooConverter fooConverter; + public String use() { return fooConverter.convert("input"); } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const use = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'com.example.web::Handler::use'); + expect(use).toBeDefined(); + const calls = cg.getOutgoingEdges(use!.id).filter((e) => e.kind === 'calls'); + expect(calls.length).toBeGreaterThanOrEqual(1); + + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('convert'); + expect(target?.filePath.replace(/\\/g, '/')).toBe( + 'service/src/main/java/com/example/service/converter/FooConverter.java' + ); + }); + + it('C# extracts references from method/property/field types (#381)', async () => { + // Pre-#381, every C# project produced ZERO `references` edges: + // csharp.ts was missing returnField, and the type-leaf walker + // only recognized TS/Java's `type_identifier` nodes — C# uses + // `identifier`/`predefined_type`/`qualified_name`/`generic_name`. + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync( + path.join(srcDir, 'Dtos.cs'), + `namespace MyApp; +public class SessionInfoDto { public string Id { get; set; } = ""; } +public class UserDto { public string Name { get; set; } = ""; } +` + ); + fs.writeFileSync( + path.join(srcDir, 'Service.cs'), + `using System.Threading.Tasks; +namespace MyApp; +public class DataExporter +{ + public SessionInfoDto Build(UserDto user, SessionInfoDto session) { return session; } + public Task BuildAsync(UserDto user) { return Task.FromResult(new SessionInfoDto()); } + public SessionInfoDto Latest { get; set; } = new(); + private UserDto _cached; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const sessionDto = cg + .getNodesByKind('class') + .find((n) => n.name === 'SessionInfoDto'); + const userDto = cg + .getNodesByKind('class') + .find((n) => n.name === 'UserDto'); + expect(sessionDto).toBeDefined(); + expect(userDto).toBeDefined(); + + const sessionIncoming = cg + .getIncomingEdges(sessionDto!.id) + .filter((e) => e.kind === 'references'); + const userIncoming = cg + .getIncomingEdges(userDto!.id) + .filter((e) => e.kind === 'references'); + + // SessionInfoDto: Build return, Build param, BuildAsync return (inside Task<>), Latest property. + // UserDto: Build param, BuildAsync param, _cached field. + expect(sessionIncoming.length).toBeGreaterThanOrEqual(4); + expect(userIncoming.length).toBeGreaterThanOrEqual(3); + }); + + it('C# primary-constructor parameters record their type dependencies (#237)', async () => { + // C# 12 primary constructors declare a type's injected dependencies inline + // (`class Svc(IRepo repo, [FromKeyedServices("k")] ICache cache)`). Each + // ctor parameter's type is recorded as a `references` edge from the class, + // so a DI-registered contract reached only through a primary ctor is no + // longer reported as having no dependents. + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src', 'Contracts.cs'), + `namespace App; +public interface IRepo { } +public class ICache { } +` + ); + fs.writeFileSync( + path.join(tempDir, 'src', 'OrderService.cs'), + `namespace App; +public sealed class OrderService(IRepo repo, [FromKeyedServices("primary")] ICache cache) +{ + public void Run() { } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const svc = cg.getNodesByKind('class').find((n) => n.name === 'OrderService'); + expect(svc).toBeDefined(); + // The class itself must index (it used to vanish under the old grammar). + const out = cg.getOutgoingEdges(svc!.id).filter((e) => e.kind === 'references'); + const depNames = out.map((e) => cg.getNode(e.target)?.name); + expect(depNames).toContain('IRepo'); + expect(depNames).toContain('ICache'); // the keyed-DI ([FromKeyedServices]) dependency + }); + + it('Go: leaves stdlib calls (fmt.Println, etc.) external', async () => { + fs.writeFileSync( + path.join(tempDir, 'go.mod'), + 'module github.com/example/myproject\n\ngo 1.21\n' + ); + fs.writeFileSync( + path.join(tempDir, 'main.go'), + `package main + +import "fmt" + +func main() { + fmt.Println("hi") +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const mainFn = cg.getNodesByKind('function').filter((n) => n.name ==='main')[0]; + const calls = cg.getOutgoingEdges(mainFn!.id).filter((e) => e.kind === 'calls'); + // No spurious in-project edge — fmt.* must stay unresolved/external. + expect(calls).toHaveLength(0); + }); }); describe('Name Matcher: kind bias for new ref kinds', () => { @@ -845,5 +1402,2032 @@ def bootstrap(): const callers = cg.getCallers(signInNode!.id); expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); }); + + it('follows a default re-export of a .svelte component (export { default as Foo } from ./RealButton.svelte) (#629)', async () => { + // The ubiquitous Svelte/React component-barrel form. The leaf is a + // .svelte component (extracted as kind 'component', the default + // export). The re-export ALIAS (`Foo`) deliberately differs from the + // component's real name (`RealButton`) so the name-matcher fallback + // can't coincidentally connect them — the only path to the edge is + // the import-chase, which must match a `component` (not just + // function/class) for the default export. Otherwise the + // consumer↔component edge is never created and `callers` returns a + // false 0. + fs.mkdirSync(path.join(tempDir, 'src/lib'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/lib/RealButton.svelte'), + `\n\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/lib/index.ts'), + `export { default as Foo } from './RealButton.svelte';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/Bar.svelte'), + `\n\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const fooNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'RealButton' && n.filePath === 'src/lib/RealButton.svelte'); + expect(fooNode).toBeDefined(); + const callers = cg.getCallers(fooNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true); + }); + + it('links an .astro page to the component and TS util it uses (#768)', async () => { + // The canonical Astro shape: a page imports a layout/component in + // frontmatter and uses it as a template tag; the component's template + // calls an imported .ts util. Both hops must produce graph edges or + // an Astro project is invisible to callers/impact. + fs.mkdirSync(path.join(tempDir, 'src/components'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'src/pages'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/utils/format.ts'), + `export function formatDate(d: Date): string { return d.toISOString(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/components/PostCard.astro'), + `---\nimport { formatDate } from '../utils/format';\nconst { date } = Astro.props;\n---\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/pages/index.astro'), + `---\nimport PostCard from '../components/PostCard.astro';\n---\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + // Hop 1: page → component (template tag through the frontmatter import) + const cardNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'PostCard' && n.filePath === 'src/components/PostCard.astro'); + expect(cardNode).toBeDefined(); + const cardCallers = cg.getCallers(cardNode!.id); + expect(cardCallers.some((c) => c.node.filePath === 'src/pages/index.astro')).toBe(true); + + // Hop 2: component template call → .ts util + const fmtNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'formatDate' && n.filePath === 'src/utils/format.ts'); + expect(fmtNode).toBeDefined(); + const fmtCallers = cg.getCallers(fmtNode!.id); + expect(fmtCallers.some((c) => c.node.filePath === 'src/components/PostCard.astro')).toBe(true); + }); + + it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => { + // `import { helper } from '.'` (or './') must map to the + // directory's index.ts before the re-export chase can run. The + // barrel renames `realHelper` → `helper` so the name-matcher can't + // mask a path-resolution failure: only the bare-dir resolution + + // rename chase can connect the edge. + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'src/util.ts'), + `export function realHelper(): void {}\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/index.ts'), + `export { realHelper as helper } from './util';\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main.ts'), + `import { helper } from '.';\nexport function go(): void { helper(); }\n` + ); + fs.writeFileSync( + path.join(tempDir, 'src/main2.ts'), + `import { helper } from './';\nexport function go2(): void { helper(); }\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const helperNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'realHelper' && n.filePath === 'src/util.ts'); + expect(helperNode).toBeDefined(); + const callers = cg.getCallers(helperNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); + expect(callers.some((c) => c.node.filePath === 'src/main2.ts')).toBe(true); + }); + + it('resolves a workspace package-subpath barrel (@scope/pkg/sub) to its index (#629)', async () => { + // bun/npm/pnpm workspace: `@scope/ui/widgets` → the `ui` package's + // `widgets/` subdir index, which re-exports a .svelte component. + // Alias `Thing` ≠ component `Widget` defeats the name-matcher, so + // only workspace-package resolution can connect the edge. + fs.mkdirSync(path.join(tempDir, 'packages/ui/widgets'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }, null, 2) + ); + fs.writeFileSync( + path.join(tempDir, 'packages/ui/package.json'), + JSON.stringify({ name: '@scope/ui', version: '1.0.0' }, null, 2) + ); + fs.writeFileSync( + path.join(tempDir, 'packages/ui/widgets/Widget.svelte'), + `\n\n\n` + ); + fs.writeFileSync( + path.join(tempDir, 'packages/ui/widgets/index.ts'), + `export { default as Thing } from './Widget.svelte';\n` + ); + fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'app/App.svelte'), + `\n\n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const buttonNode = cg + .getNodesByKind('component') + .find((n) => n.name === 'Widget' && n.filePath === 'packages/ui/widgets/Widget.svelte'); + expect(buttonNode).toBeDefined(); + const callers = cg.getCallers(buttonNode!.id); + expect(callers.some((c) => c.node.filePath === 'app/App.svelte')).toBe(true); + }); + + it('resolves a barrel import from a Vue SFC \n\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const runNode = cg + .getNodesByKind('function') + .find((n) => n.name === 'realRun' && n.filePath === 'src/util.ts'); + expect(runNode).toBeDefined(); + const callers = cg.getCallers(runNode!.id); + expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true); + }); + + it('follows a Vue component used in a