Compare commits

...
Author SHA1 Message Date
abhigyanpatwariandClaude Sonnet 4.6 9c78439d24 fix(setup): strip \r from resolveGitnexusBin on Windows multi-match
Move .trim() after .split('\n')[0] so the trailing carriage return is
removed from the first line when `where` returns multiple results on Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:58:13 +05:30
Abhigyan PatwariandClaude Opus 4.6 c5dcc0172a fix(setup): prefer global gitnexus binary over npx for MCP config
`npx -y gitnexus@latest mcp` with a cold cache takes ~110s to resolve
native dependencies (tree-sitter, onnxruntime), exceeding Claude Code's
30s MCP connection timeout. `getMcpEntry()` now checks for a globally
installed `gitnexus` binary first (starts in ~1s) and only falls back
to npx when not found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:11:47 +05:30
Gergő Magyar 0561d24efd feat: METHOD_IMPLEMENTS edges, overload disambiguation, MethodExtractor unification (#574) (#642) 2026-04-04 18:41:47 +01:00
Abhigyan Patwari 153262304c fix(mcp): unify stdout silencing to prevent embedder/pool-adapter conflicts (#645) 2026-04-04 11:56:49 +01:00
Abhigyan Patwari 16cf4c503e fix(web): replace aggressive heartbeat disconnect with graceful reconnection (#643) 2026-04-04 11:56:35 +01:00
Abhigyan Patwari 57951a197b fix(web): scope all backend calls to the active repo, not always the first (#644) 2026-04-04 11:55:34 +01:00
Gergő MagyarandClaude Opus 4.6 63fc4c795f feat: MethodExtractor configs for Python, PHP, Swift, Dart, Rust, Ruby (#624)
* feat: MethodExtractor configs for Python, PHP, Swift, Dart, Rust, Ruby with exhaustive integration tests

Add per-language MethodExtractionConfig for all remaining tree-sitter languages
(RFC #568 PR 2). Each config follows the established createMethodExtractor()
factory pattern — no new types, no parse-worker changes.

Configs:
- Python: @abstractmethod, @staticmethod/@classmethod, *args/**kwargs, type hints, _/__ visibility
- PHP: abstract/final/static keywords, PHP 8 #[] attributes, __construct/__destruct
- Swift: 5-level visibility, protocol-as-abstract, static/class methods, @ attributes
- Dart: _ convention visibility, abstract (no body), method_signature unwrapping
- Rust: pub visibility, &self receiver, trait_item + impl_item, #[] attributes
- Ruby: positional visibility via sibling-walk, singleton_method as static

Integration fixtures (18 directories) covering 3 resolution patterns:
- Method enrichment: parameterTypes, isAbstract, isFinal, annotations on graph nodes
- Overload dispatch: arity-based CALLS resolution via parameterTypes
- Abstract dispatch: abstract/concrete method distinction (Python, PHP, Rust, Swift)

Go deferred — requires factory changes for receiver-based method extraction.

Closes #571

* fix: address code review findings across 6 MethodExtractor configs

Fix all actionable items from the PR #624 deep-dive review:

Dart (critical — fixes 6 CI failures):
- isDartStatic: check children first, siblings as fallback
- isDartAbstract: handle declaration nodes for abstract methods
- extractSingleParam: detect required keyword as sibling token
- Add declaration to methodNodeTypes, mixin_declaration to typeDeclarationNodes
- Add member call query for variable assignments in tree-sitter-queries

Python:
- hasDecorator now matches dotted paths (e.g. @abc.abstractmethod)
- Fix version comment from ^0.23.6 to 0.23.4

PHP:
- Add enum_declaration to typeDeclarationNodes (PHP 8.1+)
- Add version comment for 0.23.12

Swift:
- Add isOverride using hasKeyword/hasModifier pattern

Rust:
- Fix version comment from ^0.23.2 to 0.23.1

Also: identifier fallback in generic.ts for mixin owner names,
Dart integration test label fix (Method vs Function), version
comment for tree-sitter-dart 1.0.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Dart extension_declaration and Ruby module_function support

Dart:
- Add extension_declaration to typeDeclarationNodes and extension_body
  to bodyNodeTypes — extension methods are now extracted into the graph
- Add extension_declaration and mixin_declaration to CLASS_CONTAINER_TYPES
  for HAS_METHOD edge resolution

Ruby:
- module_function now maps to visibility 'private' in extractRubyVisibility
- module_function methods marked isStatic via backward-walk in isStatic
- Override semantics: private/public after module_function resets isStatic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(go): Go MethodExtractor config with receiver-based extraction

Add Go as the 13th language with a per-language MethodExtractor config.
Go methods are top-level (not nested in struct bodies), so this adds
extractFromNode() to the MethodExtractor interface for direct method
node extraction without an enclosing class.

Config extracts:
- Name from field_identifier (methods) / identifier (functions)
- Return type including multi-return (first type from parameter_list)
- Parameters with variadic support
- Visibility via uppercase/lowercase convention
- Receiver type with pointer unwrapping (*User → User)
- isStatic for functions (no receiver)

Infrastructure:
- extractOwnerName optional hook on MethodExtractionConfig
- extractFromNode on MethodExtractor (factory auto-implements)
- Parse-worker uses extractFromNode when no enclosing class found
- method_declaration added to CLASS_CONTAINER_TYPES

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: method enrichment integration tests for 7 languages + TS abstract class fix

Add method-enrichment integration test fixtures and test blocks for
Go, C++, Java, Kotlin, TypeScript, JavaScript, and C#. Each fixture
tests: class detection, HAS_METHOD edges, EXTENDS edges, isAbstract,
isStatic, annotations, parameterTypes, and CALLS edge resolution.

Fixes found during testing:
- Remove method_declaration from CLASS_CONTAINER_TYPES (added for Go
  but broke Java/C# HAS_METHOD edge resolution — method_declaration
  is also Java's method node type)
- Add abstract_class_declaration query to TypeScript tree-sitter
  queries (was missing, so abstract classes were invisible to pipeline)

1699 integration tests pass across 20 test files, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format typeDeclarationNodes array for better readability in PHP config

* fix: Go interface methods + Rust impl-for-Struct owner resolution

Go:
- Add method_elem to methodNodeTypes so interface method signatures
  are extractable as abstract methods
- Integration test: Animal interface detected, Speak isAbstract,
  CALLS edges from app.go

Rust:
- Add extractOwnerName to resolve impl Trait for Struct to the
  concrete Struct (not the Trait) — fixes method misattribution
- Fix findEnclosingClassId to generate Struct: label (not Impl:)
  for impl blocks so HAS_METHOD edges resolve to struct nodes
- Tighten abstract-dispatch test: assert SqlRepo owns find/save

generic.ts:
- Fix extractOwnerName fallback: when hook returns a value, skip
  both name-field and type_identifier scan (was overwriting result)

1703 integration tests pass, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: code review response — Rust impl label, Swift params, Dart async, sequential methodExtractor

Address code review findings from PR #624:

- ast-helpers: Rust `impl Trait for Struct` uses Struct label (matches existing
  graph node), plain `impl Struct` uses Impl label (matches definition.impl)
- swift: fix parameter type extraction (user_type not type_annotation), detect
  default values as function_declaration siblings, add version comment
- dart: isDartAsync now detects async*/sync* generators, add clarifying comment
  for declaration nodes in extension bodies
- python: correct isFinal comment (PEP 591 @typing.final exists, just not modeled)
- parsing-processor: port methodExtractor enrichment to sequential path so
  isAbstract/isStatic/visibility/annotations/isFinal populate on <15-file repos
- tests: remove silent `if (prop !== undefined)` guards, assert properties
  directly, fix label queries (Dart Method vs Function, Swift Method for protocol
  methods), add Rust HAS_METHOD sourceLabel tests, Swift parameterTypes tests,
  and Dart async/sync* integration tests with fixture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Rust grammar gap + qualified method IDs to resolve same-file collisions

Phase 1 — Rust grammar:
- Add function_signature_item query to RUST_QUERIES so abstract trait methods
  (fn speak(&self) -> String;) become graph nodes with isAbstract=true

Phase 2 — Qualified method IDs:
- findEnclosingClassInfo returns {classId, className} for AST-based class lookup
- Both parsing paths (sequential + worker) qualify method/property IDs with
  enclosing class: Method:file:ClassName.method instead of Method:file:method
- extractFuncNameFromSourceId handles ClassName.method format
- Fixes silent data loss when same-name methods in different classes shared a
  file (e.g., Animal.speak and Dog.speak both now exist as distinct graph nodes)

Test updates:
- Rust: abstract+concrete trait methods both verified, function count adjusted
- Python: static method disambiguation now emits 2 CALLS edges (correct — no
  more ID collision masking the second call)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: owner-aware resolution for qualified method IDs

Address Codex adversarial review findings after qualified ID change:

- findEnclosingFunction: disambiguate candidates by ownerId when multiple
  same-name methods exist in file; qualify fallback-generated IDs
- findEnclosingFunctionId (worker): qualify sourceIds with enclosing class
  name so CALLS source attribution matches definition-phase node IDs
- buildExportedTypeMapFromGraph: use lookupExactAll + nodeId match instead
  of lookupExactFull which returns first definition for bare name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: methodExtractor variadic arity, return type preservation, PHP abstract dispatch

Three bugs in the methodExtractor enrichment path broke 17 integration tests:

1. Variadic parameterCount: buildMethodProps and parse-worker set
   parameterCount = info.parameters.length even for variadic functions,
   causing arity filtering to reject valid calls. Now checks isVariadic
   and sets parameterCount = undefined (matching extractMethodSignature).

2. C++ bare `...` token: extractCppParameters only iterated named
   children, missing the unnamed `...` token in C-style variadics like
   log_entry(const char* fmt, ...). Added fallback scan of all children.

3. Return type stripping: All 11 language extractReturnType functions
   used extractSimpleTypeName() which strips generic parameters
   (List<User> → "List", Task<User> → "Task"). Changed to .text?.trim()
   to preserve full generic types needed for for-loop iterable resolution,
   async-await binding, and return-type inference.

Also fixes PHP abstract dispatch test that matched SqlRepository instead
of the interface due to ambiguous filePath.includes('Repository') filter,
and adds parent-walk fallback in PHP isAbstract for extractFromNode path.

* chore: remove plan and review artifacts from PR

* fix: address Round 4 review findings + infrastructure improvements

- Ruby: add singleton_class support for class << self methods (4 new tests)
- PHP: add enum_declaration to CLASS_CONTAINER_TYPES
- Dart: add mixin/extension labels to CONTAINER_TYPE_TO_LABEL
- Swift: add TODO for unverifiable struct/enum node types on Node 22
- C#: add grammar version comment (0.23.1)
- Ruby: fix version comment range to pin (0.23.1)
- Rust/ast-helpers: add cross-reference comments for impl_item duplication
- ast-helpers: document CLASS_CONTAINER_TYPES ↔ typeDeclarationNodes invariant
- generic.ts: replace Array.includes with Set for O(1) dedup in addNestedBodies
- Go/Python/Ruby: align isAbstract signature with 2-param interface contract
- CLAUDE.md: fix malformed backtick around gitnexus:start HTML comment
- parsing-processor: add per-class method extraction cache (eliminates O(N*M))
- ast-helpers: add scoped_type_identifier to impl_item resolution
- call-processor: add dev-mode warnings at silent candidates[0] fallbacks
- MCP context(): surface methodMetadata for Method/Function/Constructor nodes
- resources.ts: update schema to list all stored Method properties

* fix: singleton_class HAS_METHOD edge regression in findEnclosingClassInfo

singleton_class (class << self) was added to CLASS_CONTAINER_TYPES but
has no name field — its receiver `self` has node type 'self', not
'identifier'. findEnclosingClassInfo now walks up to the enclosing
class/module to inherit its name, matching ruby.ts:extractOwnerName.

Also fixes findEnclosingClassNode in parse-worker.ts to skip
singleton_class and return the actual class/module node.

Adds integration test assertions for from_habitat (class << self method):
HAS_METHOD edge from Animal, isStatic=true, parameterCount=1.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:11:31 +01:00
Abhigyan Patwari 5c4fca21c3 Merge pull request #626 from ivkond/feat/intra-repo-service-tracking-clean
[group] Intra-repo service communication tracking
2026-04-03 17:04:24 +05:30
Nguyen Hai SonandClaude Opus 4.6 dd0f5eed7d feat(vue): Vue SFC support + destructured call result tracking (#604)
* feat(vue): add Vue SFC (.vue) support for indexing

Vue Single File Components are now fully supported in the indexing pipeline.
The implementation extracts <script> / <script setup> blocks from .vue files
and parses them using the existing TypeScript tree-sitter grammar — no new
npm dependencies required.

Key changes:
- SFC script extractor: regex-based extraction of <script setup lang="ts">
  blocks with correct line offset mapping back to the .vue file
- Vue language provider: reuses TypeScript queries, type config, field
  extractors, and named binding extraction
- Import resolution: .vue added to EXTENSIONS so `import Foo from './Foo'`
  resolves to Foo.vue; Vue import resolver delegates to TS resolver for
  tsconfig path alias support
- Export detection: <script setup> top-level bindings are implicitly exported
- Template component detection: PascalCase tags in <template> emit CALLS edges
- Line offsets applied to all emitted positions (startLine, endLine, route
  lineNumbers, decorator positions) in both worker and sequential paths

Validated on a 3,553-file Vue project:
  Before: 24,693 nodes | 73,614 edges | 0 symbols from .vue
  After:  30,495 nodes | 112,324 edges | 5,213 symbols from .vue
          18,682 imports from .vue | 5,826 vue-to-vue imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(typescript): track destructured call results in TypeEnv

Extend `extractPendingAssignment` to handle object destructuring from
function calls and await expressions:

  const { isMaker } = useUserRole()
  const { data } = await fetchData()
  const { name } = repo.getProfile()

Previously, only `const { x } = someVariable` (identifier RHS) produced
TypeEnv bindings. Call-expression RHS was silently skipped, leaving
destructured properties untracked.

The fix emits a synthetic `callResult` item plus N `fieldAccess` items
per destructured property, which the existing fixpoint resolver processes
in 2 iterations. No changes needed to type-env.ts, PendingAssignment
types, or call-processor — the existing infrastructure handles it.

Also extracts a `collectDestructuredFields` helper to share the
object_pattern property iteration logic between the identifier and
call-expression branches.

Note: Full property-type resolution requires the callee to have a
declared returnType in the SymbolTable. Arrow-function composables
without type annotations (common in Vue/React) won't resolve property
types until return-type inference is added in a future change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(vue): address PR review issues for Vue SFC support

- Extract duplicated isVueSetupTopLevel to vue-sfc-extractor.ts shared
  utility, removing identical copies from parse-worker.ts and
  parsing-processor.ts
- Fix VUE_BUILT_INS to be a superset of TS BUILT_INS by importing and
  spreading the TypeScript set, preventing spurious unresolved calls for
  standard built-ins (Symbol, BigInt, WeakMap, array methods, etc.)
- Add Vue template component CALLS edge resolution in both sequential
  and worker paths (call-processor.ts), matching PascalCase template
  tags against imported .vue file basenames via the import map
- Add integration test for template PascalCase CALLS edges
  (App.vue → Button.vue)
- Add integration test for isExported: false on non-setup <script>
  blocks (OldStyle.vue options API)
- Add comment explaining TEMPLATE_RE greedy regex behavior for nested
  template tags
- Fix stale language count comment (14 → 15) and remove dead code
  branch in test

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:18:55 +05:30
Chirag Nighut e3d73a7aed Java method reference (#622) 2026-04-02 15:16:35 +01:00
ivkondandClaude Opus 4.6 255e3e79eb fix(group): address 4 HIGH-priority issues from PR #626 review
1. Path traversal via group name — add validateGroupName() with regex
   [a-zA-Z0-9][a-zA-Z0-9_-]*, called in getGroupDir (defense in depth)

2. gRPC proto regex can't handle nested braces — replace serviceRe with
   extractServiceBlocks() brace-depth counter (init depth=1, skip
   malformed protos)

3. Service boundary detector directory exclusions — add EXCLUDED_DIRS
   set (vendor, target, build, dist, __pycache__, .venv, venv, .tox,
   .mypy_cache, .gradle, .mvn, out, bin) replacing inline node_modules

4. Double-close of LadybugDB pools — remove blanket closeLbug() from
   cli/group.ts; sync.ts per-id cleanup is sufficient

Tests: 22 new tests across 5 files. Full suite: 4706 passed, 0 failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:33 +03:00
ivkondandClaude Opus 4.6 be4458b650 docs: add design spec and implementation plan for PR #626 HIGH fixes
Spec covers 4 HIGH-priority issues from review: path traversal via
group name, gRPC proto regex nested braces, service boundary detector
directory exclusions, double-close of LadybugDB pools.

Plan: 6 tasks with TDD, ordered by complexity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:18 +03:00
ivkondandClaude Opus 4.6 4fed097abb feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture
Wire extractors into the sync pipeline with service boundary detection.
GroupService provides high-level API for all group operations.

- Sync pipeline: orchestrates extraction (HTTP, gRPC, topics) with
  service boundary assignment and exact matching
- GroupService: groupList, groupSync, groupContracts, groupQuery,
  groupStatus (groupImpact deferred to cross-repo follow-up PR)
- CLI: group create/add/remove/list/sync/contracts/query/status
- MCP tools: group_list, group_sync, group_contracts, group_query,
  group_status
- Monorepo fixture: 3 services (auth/orders/gateway) connected via
  gRPC + Kafka + HTTP — all intra-repo cross-links discovered
- Documentation: CLI commands and MCP tools added to both READMEs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:31 +03:00
ivkondandClaude Opus 4.6 4fa395f4b6 feat(group): add service boundary detection and contract extractors
Service communication detection for microservice monorepos:

- ServiceBoundaryDetector: auto-detects service boundaries via markers
  (package.json, go.mod, Dockerfile, pom.xml, Cargo.toml, build.gradle,
  pyproject.toml, etc.)
- HttpRouteExtractor: graph-assisted (Strategy A) with source-scan
  fallback (Strategy B) for Spring, Express, Laravel, FastAPI providers
  and fetch/axios consumers
- GrpcExtractor: parses .proto files, detects Go/Java/Python/TS gRPC
  servers (RegisterXxxServer, @GrpcService, add_XxxServicer_to_server,
  @GrpcMethod) and clients (NewXxxClient, newBlockingStub, XxxStub)
- TopicExtractor: Kafka (@KafkaListener, producer.send), RabbitMQ
  (@RabbitListener, channel.publish/consume), NATS (nc.Subscribe/Publish)
  across Java, Node, Go, and Python

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:12 +03:00
ivkondandClaude Opus 4.6 52277247fe feat(group): add group infrastructure and contract matching
Core foundation for repository group analysis:
- Type system: ContractType, ExtractedContract, StoredContract, CrossLink
  with optional `service` field for intra-repo matching
- Config parser for group.yaml (repos, detection flags, matching thresholds)
- Contract registry storage with atomic writes
- Exact matching engine with per-type normalization (HTTP, gRPC, topic)
  and intra-repo support (different services within same repo can match)
- Extract LadybugDB pool-adapter from MCP backend for reuse by sync pipeline
- Git staleness checker for group status reporting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:39:43 +03:00
Gergő Magyar ba5de0bde4 feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#617)
* feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#572)

- Pure virtual (= 0) detected as isAbstract via token scanning
- virtual/final/override via hasKeyword and virtual_specifier children
- Access specifier visibility via backward sibling walk (public:/private:/protected:)
- Pointer/reference parameter types extracted correctly
- Constructor and destructor support via declaration node type
- Static detection via storage_class_specifier
- 16 new tests covering all acceptance criteria

* fix(cpp): isVirtual infers from override/final + out-of-class resolution

- isVirtual returns true for override/final methods (C++ mandates these
  are virtual)
- Add findClassNodeByQualifiedName to parse-worker: resolves Foo::bar()
  back to the Foo class declaration for method extractor enrichment
- Handles pointer/ref return types, constructors, destructors
- Integration test for virtual/static/constructor inline methods
- 233 unit+integration tests pass, 97 C++ resolver tests pass

* fix(cpp): address review — deep pointers, templates, unions, trailing returns

- Fix extractParamName: recursive unwrap for int** ptr → "ptr" (not "**ptr")
- Fix findFunctionDeclarator: recursive unwrap for multi-level pointer chains
- Template methods: generic extractor unwraps template_declaration to inner node
- union_specifier: added to typeDeclarationNodes, visibility defaults to public
- Trailing return type: auto foo() -> T now extracts T instead of "auto"
- Fix version comment: ^0.22.4 → ^0.23.4 to match package.json
- 4 new tests: double pointer params, template methods, union methods, trailing returns

* fix(cpp): template method visibility + union isTypeDeclaration test

extractCppVisibility now walks from the template_declaration parent
when the node is wrapped by a template, restoring correct access-
specifier resolution for templated class methods.

Also adds missing isTypeDeclaration assertion for union_specifier and
expands the template method test with explicit visibility checks.

* fix(cpp): address deep gap analysis review findings

- findClassNodeByQualifiedName: recursive pointer/reference
  declarator unwrap, fixing out-of-class linking for deep pointer
  return types (e.g. int** Foo::bar())
- findClassNodeByQualifiedName: recurse into namespace_definition
  blocks so namespace-wrapped classes resolve correctly
- Suppress = delete / = default special members from extraction
  via delete_method_clause / default_method_clause node detection
- Update known-gaps: namespace-wrapped classes, const-overload collapse
- Add tree-sitter-c version comment for consistency
- toBeFalsy() → toBe(undefined) for precise isVirtual assertion
- Tests: = delete, = default, = 0 non-regression, operator overloads,
  deep pointer return types, default visibility (class vs struct),
  multiple access specifier sections
2026-04-01 18:07:11 +01:00
Abhigyan PatwariandClaude Opus 4.6 dc86ea96dc chore: release v1.5.3 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:35:24 +05:30
Abhigyan PatwariandClaude Opus 4.6 d1adc8331a chore: release v1.6.0 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:32:11 +05:30
80d363f145 fix(wiki): Azure OpenAI compat and HTML viewer script injection (#618)
* fix(wiki): Azure OpenAI compat and HTML viewer script injection

- Use max_completion_tokens instead of deprecated max_tokens for all models
- Skip sending temperature for Azure provider (some models reject non-default values)
- Simplify Azure interactive setup: endpoint + deployment + key (3 prompts instead of 7)
- Escape </script> in embedded JSON to prevent premature script tag closure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): align wiki-llm-client test with max_completion_tokens change

The test expected max_tokens for non-reasoning models, but the source
now uses max_completion_tokens for all models since max_tokens is
deprecated by newer OpenAI models.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:30:43 +05:30
Gergő Magyar 12be2025f1 feat(ts,js): TypeScript/JavaScript MethodExtractor config (#588)
* feat(ts,js): MethodExtractor config for TypeScript and JavaScript (#570)

Add per-language method extraction config following the established
JVM and C# patterns. Shared config base mirrors the field extractor's
typescript-javascript.ts pattern — TS-only node types are harmless
no-ops for JS.

Key features:
- isAbstract for abstract class methods and interface methods
- Parameter extraction with isOptional (?:, defaults) and isVariadic (...)
- Decorator extraction from preceding body-level siblings
- isAsync and isOverride detection
- Visibility via accessibility_modifier two-pass pattern
- Return type extraction unwrapping type_annotation

* test(ts,js): add override, getter/setter, destructured param tests

Address code review findings:
- Add override method detection test
- Add getter/setter extraction test
- Add destructured parameter with type annotation test
- Tighten constructor and private method assertions

* refactor(ts,js): address code review findings

- Replace O(M*N) decorator index scan with previousNamedSibling walk
- Remove dead findVisibility 'modifiers' fallback (TS uses
  accessibility_modifier, not a modifiers wrapper)
- Document call_signature/construct_signature as known gaps
- Document that TS constructors are method_definition nodes
- Remove unused findVisibility import

* fix(ts,js): type guard before cast, add generator/computed/overload tests

- Use type guard pattern (Set.has check before as-cast) in visibility
  extraction to ensure string is validated before narrowing
- Add generator method test (*items()) — confirms extraction works
- Add computed property name test ([Symbol.iterator]) — documents
  bracket-in-name behavior as intentional
- Add class-level method overload test — verifies overload signatures
  + implementation are all extracted

* fix(ts,js): detect #private methods as visibility 'private'

ES2022 private class methods (#name) use private_property_identifier
as their name node type. Detect this and return 'private' visibility
instead of the default 'public'.

* fix(ts,js): address review findings + close ingestion gaps

- hasKeyword/findVisibility: skip name field child to prevent false
  positives on soft-keyword method names (e.g. `abstract()`, `static()`)
- extractTsJsParameters: filter TS `this` parameter (compile-time only)
- extractMethodSignature: mirror `this`-param skip in fallback path
- tree-sitter queries: capture abstract_method_signature,
  method_signature, and private_property_identifier for TS; add
  private_property_identifier for JS
- Remove dead childForFieldName('name') fallbacks and typeFromAnnotation
  fallback
- Add 10+ unit tests, 4 integration tests through query pipeline

* test(ts): update HAS_METHOD count for interface method_signature capture

The new method_signature query now captures ILogger.log() as a Method
node with a HAS_METHOD edge, increasing the expected count from 4 to 5.

* fix(ts,js): address second review — async generator test, declare module gap

- Add async generator method test (async *values() → isAsync: true)
- Document declare module/global augmentation as known gap
2026-04-01 14:09:59 +01:00
Abhigyan PatwariandClaude Opus 4.6 dcf40da3a8 fix: ensure import rewrites survive npm publish lifecycle
npm runs `prepare` after `prepack` during publish, so the previous
`prepare: tsc` overwrote the rewritten imports before packing.
Both `prepare` and `prepack` now run the full build script so the
tarball always contains rewritten relative imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:28:14 +05:30
Abhigyan PatwariandClaude Opus 4.6 c617350c58 chore: release v1.5.1 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:05:08 +05:30
71255a096c fix: bundle gitnexus-shared into CLI dist (#613)
* fix(ci): build gitnexus-shared before publish, use CHANGELOG for release notes

The publish workflow was missing the gitnexus-shared build step that
the setup-gitnexus composite action provides. Since PR #536 unified
the ingestion pipeline, gitnexus imports types from gitnexus-shared,
so it must be built first.

Also replaces generate_release_notes with CHANGELOG.md extraction so
GitHub Releases use the reviewed changelog entry instead of a flat
PR title list.

Made-with: Cursor

* fix: bundle gitnexus-shared into CLI dist to fix module resolution

gitnexus-shared was declared as a file: dependency but never published
to npm, causing ERR_MODULE_NOT_FOUND for users installing gitnexus
globally. The build script now copies gitnexus-shared/dist into
dist/_shared/ and rewrites bare specifiers to relative paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move gitnexus-shared to devDependencies, use tsc for prepare

gitnexus-shared must remain available for tsc to resolve imports during
development/CI, but is not needed at runtime since it's bundled into
dist/_shared/. Moving it to devDependencies keeps it out of production
installs while allowing compilation. The prepare script now runs plain
tsc (no shared bundling needed for local dev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:04:39 +05:30
Gergo Magyar 39322e37df fix(ci): build gitnexus-shared before npm ci in publish workflow
The publish job failed because npm ci triggers the prepare script (tsc)
before gitnexus-shared types are available. Add the same build step
that the setup-gitnexus composite action uses in CI.
2026-04-01 07:35:46 +01:00
254 changed files with 21998 additions and 2451 deletions
+20 -1
View File
@@ -30,6 +30,10 @@ jobs:
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- run: npm ci
working-directory: gitnexus
@@ -63,7 +67,22 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Extract release notes from CHANGELOG
id: changelog
shell: bash
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
if [ -z "$NOTES" ]; then
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
echo "fallback=true" >> "$GITHUB_OUTPUT"
else
echo "$NOTES" > /tmp/release-notes.md
echo "fallback=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
generate_release_notes: true
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
+1 -1
View File
@@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus**. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. For current symbol stats, run `npx gitnexus analyze` and inspect `.gitnexus/meta.json`.
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+32
View File
@@ -58,8 +58,40 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
## Known limitations
### Overloaded method resolution
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
disambiguate overloaded methods. Two overloads with different parameter counts
produce distinct graph nodes: `Method:file:Class.method#1` vs
`Method:file:Class.method#2`.
**Remaining limitation — same-arity overloads:** When two overloads share the
same parameter count but differ only in types (e.g. `save(int)` vs
`save(String)`), they still share a node ID. This is rare in practice; a future
enhancement may add type-hash disambiguation for languages with reliable type
extraction (see issue #574).
**Variadic method matching:** When one side is variadic (`parameterCount`
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
emitted with confidence 0.7 instead of 1.0. Variadic methods like
`foo(String... args)` may superficially match `foo(String s)` by type but
are not guaranteed to be interchangeable across all languages (Java/Kotlin
accept this via varargs sugar; TypeScript, C#, Rust do not).
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
| Match quality | Confidence | When |
|---|---|---|
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
## Related docs
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
- [TESTING.md](TESTING.md) — how to run tests.
+13
View File
@@ -10,6 +10,19 @@ All notable changes to GitNexus will be documented in this file.
- Added automatic cleanup of stale KuzuDB index files
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
## [1.5.3] - 2026-04-01
### Added
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
### Fixed
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)
- Ensure import rewrites survive npm publish lifecycle
## [1.4.0] - 2026-03-13
### Added
+103 -1
View File
@@ -49,4 +49,106 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
## GitNexus rules
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+27
View File
@@ -0,0 +1,27 @@
# Migration Guide
## OVERRIDES → METHOD_OVERRIDES (PR #642)
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
consistency with the new `METHOD_IMPLEMENTS` edge type.
### Do I need to migrate?
**No.** Backward compatibility is handled automatically at runtime:
- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all
impact-analysis and context queries. Existing stored graphs with `OVERRIDES`
edges continue to return correct results without any manual intervention.
- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher
queries that reference either will work.
### What happens on re-index?
Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES`
edges going forward. The old `OVERRIDES` edges are replaced as part of the
normal full re-index.
### When will the legacy alias be removed?
The `OVERRIDES` compat alias will remain until a future major version. Removal
will be announced in this file and in the changelog before it happens.
+16 -1
View File
@@ -206,11 +206,21 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate repository wiki from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```
### What Your AI Agent Gets
**7 tools** exposed via MCP:
**16 tools** exposed via MCP (11 per-repo + 5 group):
| Tool | What It Does | `repo` Param |
| ------------------ | ----------------------------------------------------------------- | -------------- |
@@ -221,6 +231,11 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
| `cypher` | Raw Cypher graph queries | Optional |
| `group_list` | List configured repository groups | — |
| `group_sync` | Extract contracts and match across repos/services | — |
| `group_contracts`| Inspect extracted contracts and cross-links | — |
| `group_query` | Search execution flows across all repos in a group | — |
| `group_status` | Check staleness of repos in a group | — |
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
@@ -0,0 +1,725 @@
# PR #626 HIGH-Priority Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.
**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed.
**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs
**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md`
**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo.
---
### Task 1: Path Traversal — Validate Group Name
**Files:**
- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir)
- Test: `gitnexus/test/unit/group/storage.test.ts`
- [ ] **Step 1: Write failing tests for validateGroupName**
In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`:
```typescript
describe('validateGroupName', () => {
it('test_validateGroupName_traversal_path_throws', () => {
expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_slash_in_name_throws', () => {
expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_empty_string_throws', () => {
expect(() => validateGroupName('')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_starts_with_dash_throws', () => {
expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_starts_with_underscore_throws', () => {
expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_dots_throws', () => {
expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_valid_alphanumeric_passes', () => {
expect(() => validateGroupName('my-group_01')).not.toThrow();
});
it('test_validateGroupName_single_char_passes', () => {
expect(() => validateGroupName('A')).not.toThrow();
});
it('test_validateGroupName_all_digits_passes', () => {
expect(() => validateGroupName('123')).not.toThrow();
});
});
describe('getGroupDir rejects invalid names', () => {
it('test_getGroupDir_traversal_throws', () => {
expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/);
});
it('test_getGroupDir_valid_name_returns_path', () => {
const dir = getGroupDir(tmpDir, 'company');
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
});
});
describe('createGroupDir rejects invalid names', () => {
it('test_createGroupDir_traversal_throws', async () => {
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw.
- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir**
In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it:
```typescript
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
export function validateGroupName(name: string): void {
if (!GROUP_NAME_RE.test(name)) {
throw new Error(
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
);
}
}
export function getGroupDir(gitnexusDir: string, groupName: string): string {
validateGroupName(groupName);
return path.join(gitnexusDir, 'groups', groupName);
}
```
`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts
git commit -m "fix(group): validate group name to prevent path traversal
Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*.
Called in getGroupDir (defense in depth) which covers all CLI entry
points: create, add, remove, status, sync.
Addresses PR #626 review item 1 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Directory Exclusions in Service Boundary Detector
**Files:**
- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs)
- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts`
- [ ] **Step 1: Write failing tests for excluded directories**
Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`:
```typescript
it('test_detect_skips_vendor_directory', async () => {
writeFile('services/auth/package.json', '{}');
writeFile('services/auth/src/index.ts', '');
// vendor should be skipped — its contents should not create a boundary
writeFile('vendor/some-dep/package.json', '{}');
writeFile('vendor/some-dep/src/lib.go', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/auth');
expect(paths).not.toContain('vendor/some-dep');
});
it('test_detect_skips_target_directory', async () => {
writeFile('services/api/go.mod', 'module api');
writeFile('services/api/main.go', '');
writeFile('target/classes/Main.java', '');
writeFile('target/pom.xml', '<project/>');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/api');
expect(paths).not.toContain('target');
});
it('test_detect_skips_pycache_directory', async () => {
writeFile('services/ml/pyproject.toml', '[project]');
writeFile('services/ml/model.py', '');
// __pycache__ with a marker + source files — would be detected as
// a boundary if not excluded, since it has package.json + .py file
writeFile('__pycache__/package.json', '{}');
writeFile('__pycache__/cached.py', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/ml');
expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true);
});
it('test_detect_skips_dotfile_directories_regression', async () => {
writeFile('services/api/package.json', '{}');
writeFile('services/api/src/index.ts', '');
writeFile('.hidden/package.json', '{}');
writeFile('.hidden/src/index.ts', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/api');
expect(paths).not.toContain('.hidden');
});
it('test_detect_does_not_skip_regular_source_directories', async () => {
writeFile('services/api/package.json', '{}');
writeFile('services/api/src/index.ts', '');
const boundaries = await detectServiceBoundaries(tmpDir);
expect(boundaries).toHaveLength(1);
expect(boundaries[0].serviceName).toBe('api');
});
```
- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail**
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists.
- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions**
In `gitnexus/src/core/group/service-boundary-detector.ts`:
After `SOURCE_EXTENSIONS` (after line 51), add:
```typescript
const EXCLUDED_DIRS = new Set([
'node_modules',
'vendor',
'target',
'build',
'dist',
'__pycache__',
'.venv',
'venv',
'.tox',
'.mypy_cache',
'.gradle',
'.mvn',
'out',
'bin',
]);
```
In `walkForBoundaries`, replace line 78:
```typescript
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
```
with:
```typescript
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
```
In `hasSourceFilesInSubdirs`, replace line 130:
```typescript
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
```
with:
```typescript
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts
git commit -m "fix(group): add directory exclusions to service boundary detector
Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__,
.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin.
Applied in walkForBoundaries and hasSourceFilesInSubdirs.
Replaces inline node_modules check.
Addresses PR #626 review item 3 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Remove Double-Close of LadybugDB Pools
**Files:**
- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body)
- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test)
- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source)
- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts**
Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`:
```typescript
it('test_syncGroup_closes_only_opened_pools', async () => {
const config = makeConfig({
'app/backend': 'backend-repo',
'app/frontend': 'frontend-repo',
});
const closedIds: string[] = [];
// Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle
const { vi } = await import('vitest');
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => {
if (id) closedIds.push(id);
});
try {
await syncGroup(config, {
resolveRepoHandle: async (_name, groupPath) => ({
id: groupPath.replace(/\//g, '-'),
path: groupPath,
repoPath: '/tmp/' + groupPath,
storagePath: '/tmp/' + groupPath + '/.gitnexus',
}),
skipWrite: true,
}).catch(() => {});
// Regardless of extraction errors, closeLbug should be called per id
// closeLbug should only receive specific pool ids, never undefined/empty
for (const id of closedIds) {
expect(id).toBeTruthy();
expect(typeof id).toBe('string');
}
// No blanket close (no-arg call)
const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]);
expect(blanketCalls).toHaveLength(0);
} finally {
initSpy.mockRestore();
closeSpy.mockRestore();
}
});
```
- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)**
Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts`
Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior.
- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()**
Add to `gitnexus/test/integration/group/group-cli.test.ts`:
```typescript
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
const source = fs.readFileSync(cliGroupPath, 'utf-8');
// closeLbug() without arguments (blanket close) must not appear.
// closeLbug(id) with argument is fine (that's in sync.ts, not here).
// Match closeLbug() but not closeLbug(someArg)
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
expect(source).not.toMatch(blanketClosePattern);
});
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
Expected: FAIL — `closeLbug()` (no args) exists at line 188.
- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts**
In `gitnexus/src/cli/group.ts`:
Remove the `closeLbug` import at line 160:
```typescript
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
```
Replace the try/finally wrapper (lines 162-189):
```typescript
try {
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
} finally {
await closeLbug().catch(() => {});
}
```
Becomes (remove try/finally entirely, since sync.ts handles its own cleanup):
```typescript
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts`
Expected: ALL PASS
- [ ] **Step 7: Commit**
```bash
cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts
git commit -m "fix(group): remove blanket closeLbug() from CLI sync command
sync.ts already closes pools per-id in its finally block.
The blanket closeLbug() in cli/group.ts tears down ALL active pools
including unrelated ones in MCP server context.
Addresses PR #626 review item 4 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: gRPC Proto Regex — Brace-Depth Counter
**Files:**
- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile)
- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts`
- [ ] **Step 1: Write failing tests for nested braces in proto services**
Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`:
```typescript
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
writeFile(
'api/gateway.proto',
`syntax = "proto3";
package gateway.v1;
import "google/api/annotations.proto";
service GatewayService {
rpc GetUser (GetUserRequest) returns (UserResponse) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
);
expect(providers).toHaveLength(2);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::gateway.v1.GatewayService/CreateUser',
'grpc::gateway.v1.GatewayService/GetUser',
]);
});
it('test_extract_proto_with_multiple_services', async () => {
writeFile(
'api/multi.proto',
`syntax = "proto3";
package multi;
service ServiceA {
rpc MethodA (Req) returns (Res);
}
service ServiceB {
rpc MethodB1 (Req) returns (Res);
rpc MethodB2 (Req) returns (Res);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
);
expect(providers).toHaveLength(3);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::multi.ServiceA/MethodA',
'grpc::multi.ServiceB/MethodB1',
'grpc::multi.ServiceB/MethodB2',
]);
});
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
writeFile(
'api/nested.proto',
`syntax = "proto3";
package nested;
service DeepService {
rpc DeepMethod (Req) returns (Res) {
option (google.api.http) = {
post: "/v1/deep"
body: "*"
additional_bindings {
get: "/v1/deep/{id}"
}
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
);
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
});
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
writeFile(
'api/broken.proto',
`syntax = "proto3";
package broken;
service IncompleteService {
rpc SomeMethod (Req) returns (Res);
// Missing closing brace — EOF before depth returns to 0
`,
);
// Should not throw; incomplete service is silently skipped
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
);
// The old regex would find partial match; the new parser should skip it
expect(providers).toHaveLength(0);
});
```
- [ ] **Step 2: Run tests to verify the nested brace test fails**
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block.
- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function**
In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130):
```typescript
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch ? pkgMatch[1] : '';
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRe.exec(body)) !== null) {
const methodName = rpcMatch[1];
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
package: pkg,
service: serviceName,
method: methodName,
source: 'proto',
}),
);
}
}
return out;
}
```
Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26):
```typescript
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerRe.exec(content)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
while (pos < content.length && depth > 0) {
const ch = content[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
}
// If EOF before depth returns to 0, skip incomplete service
if (depth !== 0) continue;
// body is between opening { (consumed by regex) and closing } (pos is one past it)
const body = content.slice(bodyStart, pos - 1);
results.push({ name: serviceName, body });
}
return results;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
Expected: ALL PASS (including existing regression tests)
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts
git commit -m "fix(group): replace gRPC proto regex with brace-depth counter
The serviceRe regex used [^}]* which stopped at the first '}'.
Proto services with google.api.http annotations contain nested {}
blocks, causing methods to be missed.
New extractServiceBlocks() uses a brace-depth counter (init depth=1
after opening {, scan char-by-char). Malformed protos with unclosed
braces are silently skipped.
Addresses PR #626 review item 2 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Run Full Test Suite
- [ ] **Step 1: Run all group-related tests**
Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/`
Expected: ALL PASS
- [ ] **Step 2: Run full test suite to catch regressions**
Run: `cd gitnexus && npx vitest run`
Expected: ALL PASS, 0 failures
- [ ] **Step 3: Run typecheck**
Run: `cd gitnexus && npx tsc --noEmit`
Expected: No errors
---
### Task 6: CLI Integration Smoke Test
- [ ] **Step 1: Add CLI smoke test for path traversal**
Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe:
```typescript
it('test_create_with_invalid_name_fails', () => {
const result = runGroup(['create', '../../evil']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Invalid group name');
});
```
- [ ] **Step 2: Run test**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
Expected: ALL PASS
- [ ] **Step 3: Commit**
```bash
cd gitnexus && git add test/integration/group/group-cli.test.ts
git commit -m "test(group): add CLI smoke test for path traversal rejection
Verifies that 'group create ../../evil' fails with Invalid group name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
@@ -0,0 +1,175 @@
# PR #626 HIGH-Priority Fixes Design
**Date:** 2026-04-02
**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking
**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin
**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep
---
## Fix 1: Path Traversal via Group Name
**File:** `gitnexus/src/core/group/storage.ts`
**Risk:** A group name like `../../etc` creates directories outside the intended path.
### Solution
Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`.
- Call in `createGroupDir` (primary entry point)
- Call in `getGroupDir` (defense in depth)
- Throw descriptive error on invalid names
**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated.
### Why regex over path.resolve + startsWith
- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]`
- Stricter: disallows spaces, dots, Unicode edge cases
- Simpler to reason about
### Tests
- `../../evil` throws
- `foo/bar` throws
- Empty string throws
- `my-group_01` passes
- `A` (single char) passes
- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage
### CLI/API entry points accepting groupName
All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference:
| Command | Entry | Calls |
|---------|-------|-------|
| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` |
| `group add` | `cli/group.ts` action | `getGroupDir` |
| `group remove` | `cli/group.ts` action | `getGroupDir` |
| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) |
| `group status` | `cli/group.ts` action | `getGroupDir` |
| `group sync` | `cli/group.ts` action | `getGroupDir` |
**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope.
---
## Fix 2: gRPC Proto Regex -> Brace-Depth Counter
**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts`
**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks.
### Solution
Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`:
1. Use regex only to find `service <Name> {` start positions (regex consumes the opening `{`)
2. Initialise depth to 1 immediately after the opening `{`
3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body
4. Stop when depth reaches 0 (the matching closing `}`)
5. Return name + body pairs
Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body.
**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test.
**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope.
### Tests
- Proto with single service, no nesting (regression)
- Proto with `google.api.http` nested braces inside RPC options
- Proto with multiple services
- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`)
- Malformed proto with unclosed brace (graceful handling)
---
## Fix 3: Directory Exclusions in Service Boundary Detector
**File:** `gitnexus/src/core/group/service-boundary-detector.ts`
**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`.
### Solution
Create `EXCLUDED_DIRS` as a `Set<string>` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example:
```text
node_modules, vendor, target, build, dist,
__pycache__, .venv, venv, .tox, .mypy_cache,
.gradle, .mvn, out, bin
```
(Implement as `new Set([...])` — the list above is the membership, not a string literal.)
Apply in both:
- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)`
- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)`
Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`.
Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name.
Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`.
**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos.
**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code.
### Tests
- Directory named `vendor/` is skipped
- Directory named `target/` is skipped
- Directory named `__pycache__/` is skipped
- Regular source directories are NOT skipped
- Dotfile directories still skipped (regression)
---
## Fix 4: Double-Close of LadybugDB Pools
**Files:**
- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP)
- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE)
**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations.
### Solution
Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient:
```typescript
// sync.ts — KEEP: cleans up only pools opened by this sync
finally {
for (const id of [...new Set(openPoolIds)]) {
await closeLbug(id).catch(() => {});
}
}
```
```typescript
// cli/group.ts — REMOVE: blanket close that kills all pools
finally {
await closeLbug().catch(() => {}); // DELETE THIS
}
```
Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages.
### Tests (unit level — mock pool adapter)
- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids)
- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched
- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test)
---
## Out of Scope
- JSON -> LadybugDB migration (tracked in #606)
- MEDIUM/LOW issues (items 5-10 from review summary)
- Test gap coverage beyond what's needed for these 4 fixes
- Any refactoring or architectural changes
## Execution Order
Fixes are independent — can be implemented in parallel or any order.
Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex).
+2 -1
View File
@@ -97,7 +97,8 @@ export type RelationshipType =
| 'CONTAINS'
| 'CALLS'
| 'INHERITS'
| 'OVERRIDES'
| 'METHOD_OVERRIDES'
| 'METHOD_IMPLEMENTS'
| 'IMPORTS'
| 'USES'
| 'DEFINES'
@@ -41,6 +41,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
@@ -98,6 +99,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Kotlin]: 'kotlin',
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
+1
View File
@@ -19,6 +19,7 @@ export enum SupportedLanguages {
Kotlin = 'kotlin',
Swift = 'swift',
Dart = 'dart',
Vue = 'vue',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
}
+3 -1
View File
@@ -55,7 +55,9 @@ export const REL_TYPES = [
'HAS_METHOD',
'HAS_PROPERTY',
'ACCESSES',
'OVERRIDES',
'METHOD_OVERRIDES',
'OVERRIDES', // Legacy compat alias — kept until all stored indexes are migrated
'METHOD_IMPLEMENTS',
'MEMBER_OF',
'STEP_IN_PROCESS',
'HANDLES_ROUTE',
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for heartbeat disconnect/reconnect behavior.
*
* Verifies the key regression: when the heartbeat fails, the UI shows a
* "reconnecting" banner instead of resetting to the onboarding screen.
*
* Strategy: block /api/heartbeat via route interception BEFORE loading the
* graph. The heartbeat EventSource can never connect, so onReconnecting
* fires on the first retry attempt. This reliably tests the banner behavior
* without depending on setOffline timing (which varies across CI environments).
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
test.beforeAll(async () => {
if (process.env.E2E) return;
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
}
} catch {
test.skip(true, 'servers not available');
}
});
test.describe('Heartbeat Reconnect', () => {
test('shows reconnecting banner instead of onboarding reset when heartbeat is unavailable', async ({
page,
}) => {
// Block the heartbeat BEFORE navigating — the EventSource will fail
// immediately on every connection attempt, triggering onReconnecting.
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
// Load the app and connect to a repo (all other endpoints work normally)
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// auto-connect may skip the landing screen
}
// Wait for graph to load (heartbeat is blocked, but graph loads fine)
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// The reconnecting banner should appear (heartbeat is failing)
const banner = page.getByText('Server connection lost');
await expect(banner).toBeVisible({ timeout: 15_000 });
// The graph canvas should STILL be visible — NOT reset to onboarding
await expect(page.locator('canvas').first()).toBeVisible();
});
test('banner clears when heartbeat becomes available', async ({ page }) => {
// Start with heartbeat blocked
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// auto-connect may skip the landing screen
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Verify banner appears
const banner = page.getByText('Server connection lost');
await expect(banner).toBeVisible({ timeout: 15_000 });
// Unblock heartbeat — the real server is running, so reconnect will succeed
await page.unroute('**/api/heartbeat');
// Banner should disappear as heartbeat reconnects
await expect(banner).not.toBeVisible({ timeout: 30_000 });
// Graph should still be there
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for multi-repo scoping and URL persistence.
*
* Verifies that:
* - Connecting via ?server= loads data and sets ?project= in the URL
* - The repo name appears in the UI after connecting
* - F5 with ?server=&project= reconnects to the correct repo
*
* Runs against the single indexed repo in CI — validates the plumbing
* works end-to-end even with one repo.
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
let firstRepoName: string;
test.beforeAll(async () => {
if (process.env.E2E) {
// Still need to fetch the repo name for assertions
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = await res.json();
firstRepoName = repos[0]?.name ?? '';
} catch {
firstRepoName = '';
}
return;
}
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
firstRepoName = repos[0].name;
}
} catch {
test.skip(true, 'servers not available');
}
});
test.describe('Multi-Repo Scoping', () => {
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
// Navigate with ?server= param (the bookmarkable shortcut)
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// Wait for graph to load
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL should now contain ?project= with the repo name
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
expect(project).toBe(firstRepoName);
});
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL should still have ?server=
const url = new URL(page.url());
expect(url.searchParams.get('server')).toBeTruthy();
// F5 should reconnect (not show onboarding)
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
});
test('node count in status bar matches backend data', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Fetch expected node count from backend
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
const repoInfo = await res.json();
const expectedNodes = repoInfo.stats?.nodes;
if (expectedNodes) {
// Status bar shows node count — use the status-ready area to avoid
// matching multiple elements (file tree, header may also show counts)
const statusBar = page.locator('footer');
const nodeText = statusBar.getByText(/\d+ nodes/).first();
await expect(nodeText).toBeVisible({ timeout: 10_000 });
const text = await nodeText.textContent();
const displayedNodes = parseInt(text?.match(/(\d+)\s*nodes/)?.[1] ?? '0', 10);
expect(displayedNodes).toBeGreaterThan(0);
}
});
});
+78 -47
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppStateProvider, useAppState } from './hooks/useAppState';
import { DropZone } from './components/DropZone';
import { LoadingOverlay } from './components/LoadingOverlay';
@@ -36,7 +36,6 @@ const AppContent = () => {
refreshLLMSettings,
initializeAgent,
startEmbeddingsWithFallback,
embeddingStatus,
codeReferences,
selectedNode,
isCodePanelOpen,
@@ -45,17 +44,27 @@ const AppContent = () => {
availableRepos,
setAvailableRepos,
switchRepo,
setCurrentRepo,
} = useAppState();
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
const [serverDisconnected, setServerDisconnected] = useState(false);
const handleServerConnect = useCallback(
async (result: ConnectResult): Promise<void> => {
// Extract project name from repoPath
// Use the canonical repo name from the server response so all subsequent
// backend calls (queries, search, grep, readFile) scope to this repo.
const repoName = result.repoInfo.name;
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
const parts = (repoPath || '').split('/').filter((p) => p && !p.startsWith('.'));
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
const projectName =
repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project';
setProjectName(projectName);
setCurrentRepo(projectName);
// Update URL so F5 / bookmarks preserve which repo is open
const url = new URL(window.location.href);
url.searchParams.set('project', projectName);
window.history.replaceState(null, '', url.toString());
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
@@ -80,10 +89,18 @@ const AppContent = () => {
console.warn('Failed to initialize agent:', err);
}
},
[setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback],
[
setViewMode,
setGraph,
setProjectName,
setCurrentRepo,
initializeAgent,
startEmbeddingsWithFallback,
],
);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
// Auto-connect when ?server query param is present (bookmarkable shortcut).
// Also reads ?project= to connect to a specific repo.
const autoConnectRan = useRef(false);
useEffect(() => {
if (autoConnectRan.current) return;
@@ -91,9 +108,12 @@ const AppContent = () => {
if (!params.has('server')) return;
autoConnectRan.current = true;
// Clean the URL so a refresh won't re-trigger
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', cleanUrl);
const serverUrl = params.get('server') || window.location.origin;
const projectParam = params.get('project') || undefined;
// Keep ?server= in the URL so F5 reconnects to the same server.
// autoConnectRan.current prevents re-trigger within the same session.
// handleServerConnect() will add/update ?project= after connecting.
setProgress({
phase: 'extracting',
@@ -103,36 +123,39 @@ const AppContent = () => {
});
setViewMode('loading');
const serverUrl = params.get('server') || window.location.origin;
const baseUrl = normalizeServerUrl(serverUrl);
connectToServer(serverUrl, (phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
})
connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam,
)
.then(async (result) => {
await handleServerConnect(result);
setProgress(null);
@@ -169,21 +192,18 @@ const AppContent = () => {
// ── Server heartbeat: detect when server goes down while exploring ────────
// Uses SSE (EventSource) for instant detection — no polling delay.
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
// The heartbeat retries indefinitely with capped backoff and recovers automatically.
useEffect(() => {
if (viewMode !== 'exploring') return;
const cleanup = connectHeartbeat(
() => {}, // onConnect — already connected, no action needed
() => {
// Server went down — return to onboarding
setViewMode('onboarding');
setGraph(null);
setProgress(null);
},
() => setServerDisconnected(false),
() => setServerDisconnected(true),
);
return cleanup;
}, [viewMode, setViewMode, setGraph, setProgress]);
}, [viewMode]);
// Render based on view mode
if (viewMode === 'onboarding') {
@@ -196,7 +216,12 @@ const AppContent = () => {
await handleServerConnect(result);
setProgress(null);
if (serverUrl) {
setServerBaseUrl(normalizeServerUrl(serverUrl));
const base = normalizeServerUrl(serverUrl);
setServerBaseUrl(base);
// Add ?server= so F5 reconnects to this server
const url = new URL(window.location.href);
url.searchParams.set('server', base);
window.history.replaceState(null, '', url.toString());
}
}}
/>
@@ -268,6 +293,12 @@ const AppContent = () => {
<StatusBar />
{serverDisconnected && (
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
Server connection lost — reconnecting&hellip;
</div>
)}
{/* Settings Panel (modal) */}
<SettingsPanel
isOpen={isSettingsPanelOpen}
@@ -54,6 +54,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
clearCodeReferences,
setSelectedNode,
codeReferenceFocus,
projectName,
} = useAppState();
const nodeById = useMemo(() => {
@@ -174,7 +175,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
return () => {
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [codeReferenceFocus?.ts, aiReferences]);
}, [codeReferenceFocus, aiReferences]);
const refsWithSnippets = useMemo(() => {
return aiReferences.map((ref) => {
@@ -223,10 +224,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const isWholeFile = selectedIsFile || startLine === undefined;
const options = isWholeFile
? undefined
? { repo: projectName }
: {
startLine: Math.max(0, startLine - CONTEXT_LINES),
endLine: (endLine ?? startLine) + CONTEXT_LINES,
repo: projectName,
};
readFile(selectedFilePath, options)
@@ -251,6 +253,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
selectedNode?.properties?.startLine,
selectedNode?.properties?.endLine,
selectedIsFile,
projectName,
]);
// Scroll to the selected node's startLine after content loads
+11
View File
@@ -145,6 +145,7 @@ interface AppState {
availableRepos: BackendRepo[];
setAvailableRepos: (repos: BackendRepo[]) => void;
switchRepo: (repoName: string) => Promise<void>;
setCurrentRepo: (repoName: string) => void;
// Worker API (shared across app)
runQuery: (cypher: string) => Promise<any[]>;
@@ -456,6 +457,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Backend client — direct HTTP calls (no Worker/Comlink)
const repoRef = useRef<string | undefined>(undefined);
const setCurrentRepo = useCallback((repoName: string) => {
repoRef.current = repoName;
}, []);
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
return backendRunQuery(cypher, repoRef.current);
}, []);
@@ -1077,6 +1082,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setProjectName(pName);
repoRef.current = pName;
// Update URL so F5 / bookmarks open the correct repo
const url = new URL(window.location.href);
url.searchParams.set('project', pName);
window.history.replaceState(null, '', url.toString());
const newGraph = createKnowledgeGraph();
for (const node of result.nodes) newGraph.addNode(node);
for (const rel of result.relationships) newGraph.addRelationship(rel);
@@ -1219,6 +1229,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
availableRepos,
setAvailableRepos,
switchRepo,
setCurrentRepo,
runQuery,
isDatabaseReady,
// Embedding state and methods
+24 -11
View File
@@ -302,16 +302,26 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
};
/**
* Connect an SSE heartbeat to the backend. Fires `onDisconnect` when the
* server goes down (after one retry to avoid false positives from transient
* network hiccups). Returns a cleanup function.
* Connect an SSE heartbeat to the backend. Retries indefinitely with capped
* exponential backoff so transient hiccups don't reset the UI.
*
* - `onConnect` fires on every successful (re)connection.
* - `onReconnecting` fires on the first retry after a drop — use it to show
* a "reconnecting" banner while keeping the current view intact.
*
* Returns a cleanup function that tears down the EventSource and timers.
*/
export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void): (() => void) => {
export const connectHeartbeat = (
onConnect: () => void,
onReconnecting: () => void,
): (() => void) => {
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
let attempt = 0;
const MAX_RETRIES = 3;
/** Whether we've already fired onReconnecting for the current drop. */
let notifiedReconnecting = false;
const MAX_BACKOFF_MS = 15_000;
const connect = () => {
if (closed) return;
@@ -319,6 +329,7 @@ export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void
es.onopen = () => {
if (!closed) {
attempt = 0;
notifiedReconnecting = false;
onConnect();
}
};
@@ -326,13 +337,15 @@ export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void
es?.close();
es = null;
if (closed) return;
if (attempt < MAX_RETRIES) {
const delay = 1_000 * Math.pow(2, attempt);
attempt++;
retryTimer = setTimeout(connect, delay);
} else {
onDisconnect();
if (!notifiedReconnecting) {
notifiedReconnecting = true;
onReconnecting();
}
const delay = Math.min(1_000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
attempt++;
retryTimer = setTimeout(connect, delay);
};
};
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { connectHeartbeat } from '../../src/services/backend-client';
// Mock EventSource to simulate SSE behavior
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
close() {
this.closed = true;
}
}
let lastEventSource: MockEventSource | null = null;
beforeEach(() => {
lastEventSource = null;
vi.stubGlobal(
'EventSource',
vi.fn().mockImplementation(() => {
lastEventSource = new MockEventSource();
return lastEventSource;
}),
);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('connectHeartbeat', () => {
it('calls onConnect when EventSource opens', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
lastEventSource!.onopen!();
expect(onConnect).toHaveBeenCalledOnce();
expect(onReconnecting).not.toHaveBeenCalled();
});
it('calls onReconnecting on first error, then retries', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Simulate connection drop
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
expect(lastEventSource!.closed).toBe(true);
// Advance past first retry delay (1s)
vi.advanceTimersByTime(1_000);
// A new EventSource should have been created
expect(EventSource).toHaveBeenCalledTimes(2);
});
it('fires onReconnecting only once per disconnect', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// First error
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
// Second retry fires error again
vi.advanceTimersByTime(1_000);
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
// Third retry fires error
vi.advanceTimersByTime(2_000);
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
});
it('retries indefinitely instead of giving up after 3 attempts', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Simulate 10 consecutive failures — should never stop retrying
for (let i = 0; i < 10; i++) {
lastEventSource!.onerror!();
// Advance past the max backoff (15s) to ensure the next retry fires
vi.advanceTimersByTime(16_000);
}
// Should have created 11 EventSources (1 initial + 10 retries)
expect(EventSource).toHaveBeenCalledTimes(11);
});
it('resets reconnecting state when connection recovers', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Drop
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
// Retry succeeds
vi.advanceTimersByTime(1_000);
lastEventSource!.onopen!();
expect(onConnect).toHaveBeenCalledOnce();
// Drop again — should fire onReconnecting again (reset after recovery)
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledTimes(2);
});
it('caps backoff at 15 seconds', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Fail many times to push backoff past the cap
for (let i = 0; i < 6; i++) {
lastEventSource!.onerror!();
// The delay for attempt i is min(1000 * 2^i, 15000)
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
vi.advanceTimersByTime(16_000);
}
// All retries should have fired — 7 EventSources total
expect(EventSource).toHaveBeenCalledTimes(7);
});
it('stops retrying when cleanup is called', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(onConnect, onReconnecting);
lastEventSource!.onerror!();
cleanup();
// Advance time — no new EventSource should be created
vi.advanceTimersByTime(30_000);
expect(EventSource).toHaveBeenCalledTimes(1);
});
});
+11
View File
@@ -2,6 +2,17 @@
All notable changes to GitNexus will be documented in this file.
## [1.5.2] - 2026-04-01
### Fixed
- **`gitnexus-shared` module not found** — `gitnexus-shared` was a `file:` workspace dependency never published to npm, causing `ERR_MODULE_NOT_FOUND` when installing `gitnexus` globally. The build now bundles shared code into `dist/_shared/` and rewrites imports to relative paths (#613)
- **v1.5.1 publish regression** — npm's `prepare` lifecycle ran `tsc` after `prepack`, overwriting the rewritten imports before packing; both scripts now run the full build so the final tarball is always correct
## [1.5.1] - 2026-04-01 [YANKED]
### Fixed
- Incomplete fix for `gitnexus-shared` bundling — `prepare` script overwrote rewritten imports during publish
## [1.5.0] - 2026-04-01
### Added
+10
View File
@@ -164,6 +164,16 @@ gitnexus clean # Delete index for current repo
gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```
## Remote Embeddings
+31 -3
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.5.0",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.5.0",
"version": "1.5.3",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",
@@ -16,12 +16,12 @@
"commander": "^12.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"gitnexus-shared": "file:../gitnexus-shared",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"onnxruntime-node": "^1.24.0",
@@ -47,9 +47,11 @@
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
@@ -65,6 +67,7 @@
},
"../gitnexus-shared": {
"version": "1.0.0",
"dev": true,
"devDependencies": {
"typescript": "^6.0.2"
}
@@ -1992,6 +1995,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -2285,6 +2295,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -3566,6 +3582,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.5.0",
"version": "1.5.3",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -38,7 +38,7 @@
"vendor"
],
"scripts": {
"build": "tsc",
"build": "node scripts/build.js",
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
@@ -46,11 +46,10 @@
"test:integration": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "npm run build",
"prepack": "npm run build && chmod +x dist/cli/index.js"
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.js"
},
"dependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@huggingface/transformers": "^3.0.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
@@ -58,11 +57,13 @@
"commander": "^12.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"gitnexus-shared": "file:../gitnexus-shared",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"onnxruntime-node": "^1.24.0",
@@ -87,9 +88,11 @@
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Build script that compiles gitnexus and inlines gitnexus-shared into the dist.
*
* Steps:
* 1. Build gitnexus-shared (tsc)
* 2. Build gitnexus (tsc)
* 3. Copy gitnexus-shared/dist → dist/_shared
* 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
const DIST = path.join(ROOT, 'dist');
const SHARED_DEST = path.join(DIST, '_shared');
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' });
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
fs.cpSync(path.join(SHARED_ROOT, 'dist'), SHARED_DEST, { recursive: true });
// ── 4. Rewrite imports ─────────────────────────────────────────────
console.log('[build] rewriting gitnexus-shared imports…');
let rewritten = 0;
function rewriteFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.includes('gitnexus-shared')) return;
const relDir = path.relative(path.dirname(filePath), SHARED_DEST);
// Always use posix separators and point to the package index
const relImport = relDir.split(path.sep).join('/') + '/index.js';
const updated = content
.replace(/from\s+['"]gitnexus-shared['"]/g, `from '${relImport}'`)
.replace(/import\(\s*['"]gitnexus-shared['"]\s*\)/g, `import('${relImport}')`);
if (updated !== content) {
fs.writeFileSync(filePath, updated);
rewritten++;
}
}
function walk(dir, extensions, cb) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, extensions, cb);
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
cb(full);
}
}
}
walk(DIST, ['.js', '.d.ts'], rewriteFile);
// ── 5. Make CLI entry executable ────────────────────────────────────
const cliEntry = path.join(DIST, 'cli', 'index.js');
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
console.log(`[build] done — rewrote ${rewritten} files.`);
+29 -2
View File
@@ -42,10 +42,28 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
* - Exact tool commands with parameters — vague directives get ignored
* - Self-review checklist — forces model to verify its own work
*/
async function findGroupsContainingRegistryName(registryName: string): Promise<string[]> {
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const names = await listGroups();
const hits: string[] = [];
for (const g of names) {
try {
const config = await loadGroupConfig(getGroupDir(getDefaultGitnexusDir(), g));
if (Object.values(config.repos).some((r) => r === registryName)) hits.push(config.name);
} catch {
// skip invalid or unreadable groups
}
}
return hits;
}
function generateGitNexusContent(
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@@ -155,7 +173,15 @@ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`sta
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
## CLI
${
groupNames && groupNames.length > 0
? `## Cross-Repo Groups
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For blast radius across repository boundaries, use MCP tools \`group_impact\`, \`group_sync\`, \`group_query\`, \`group_contracts\`, \`group_status\`, and \`group_list\`. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
`
: ''
}## CLI
${skillsTable}
@@ -305,7 +331,8 @@ export async function generateAIContextFiles(
generatedSkills?: GeneratedSkillInfo[],
options?: AIContextOptions,
): Promise<{ files: string[] }> {
const content = generateGitNexusContent(projectName, stats, generatedSkills);
const groupNames = await findGroupsContainingRegistryName(projectName);
const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames);
const createdFiles: string[] = [];
if (!options?.skipAgentsMd) {
+298
View File
@@ -0,0 +1,298 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
export function registerGroupCommands(program: Command): void {
const group = program
.command('group')
.description('Manage repository groups for cross-index impact analysis');
group
.command('create <name>')
.description('Create a new group with template group.yaml')
.option('--force', 'Overwrite existing group')
.action(async (name: string, opts: { force?: boolean }) => {
const { createGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const dir = await createGroupDir(getDefaultGitnexusDir(), name, opts.force);
console.log(`Created group "${name}" at ${dir}`);
console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name);
});
group
.command('add <group> <groupPath> <registryName>')
.description(
'Add a repo to a group. <groupPath> = hierarchy path (e.g. hr/hiring/backend), <registryName> = name from registry',
)
.action(async (groupName: string, groupPath: string, registryName: string) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const path = await import('node:path');
const fs = await import('node:fs/promises');
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
config.repos[groupPath] = registryName;
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`);
console.log(`Run: gitnexus group sync ${groupName}`);
});
group
.command('remove <group> <path>')
.description('Remove a repo from a group')
.action(async (groupName: string, repoPath: string) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const path = await import('node:path');
const fs = await import('node:fs/promises');
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
if (!(repoPath in config.repos)) {
console.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
process.exitCode = 1;
return;
}
delete config.repos[repoPath];
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
console.log(`Removed "${repoPath}" from group "${groupName}"`);
});
group
.command('list [name]')
.description('List all groups or details of one')
.action(async (name?: string) => {
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
await import('../core/group/storage.js');
if (!name) {
const groups = await listGroups();
if (groups.length === 0) {
console.log('No groups configured. Create one with: gitnexus group create <name>');
return;
}
console.log('Groups:');
groups.forEach((g) => console.log(` ${g}`));
return;
}
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Group: ${config.name}`);
if (config.description) console.log(`Description: ${config.description}`);
console.log(`\nRepos (${Object.keys(config.repos).length}):`);
for (const [p, id] of Object.entries(config.repos)) {
console.log(` ${p} -> ${id}`);
}
if (config.links.length > 0) {
console.log(`\nManifest links (${config.links.length}):`);
for (const link of config.links) {
console.log(` ${link.from} -> ${link.to} [${link.type}: ${link.contract}]`);
}
}
});
group
.command('status <name>')
.description('Check staleness of group and repos')
.action(async (name: string) => {
const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } =
await import('../core/group/storage.js');
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
console.log(
`Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`,
);
const backend = new LocalBackend();
try {
await backend.init();
const raw = await backend.getGroupService().groupStatus({ name });
const st = raw as {
repos?: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
>;
missingRepos?: string[];
};
console.log(' Repo index / contracts staleness:');
for (const [repoPath, row] of Object.entries(st.repos || {})) {
if (row.missing) {
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
continue;
}
const idx = row.indexStale
? `STALE (${row.commitsBehind ?? '?'} commits behind)`
: 'OK ';
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
}
if ((st.missingRepos || []).length > 0) {
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
}
} finally {
await backend.dispose().catch(() => {});
}
});
group
.command('sync <name>')
.description('Sync Contract Registry — extract contracts and build cross-links')
.option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
.option('--exact-only', 'Exact match only')
.option('--allow-stale', 'Skip stale index warnings')
.option('--verbose', 'Show each cross-link detail')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
});
group
.command('query <name> <query>')
.description('Search execution flows across all repos in a group')
.option('--subgroup <path>', 'Limit search scope')
.option('--limit <n>', 'Max merged results', '5')
.option('--json', 'JSON output')
.action(
async (
name: string,
queryText: string,
opts: Record<string, string | boolean | undefined>,
) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const limit = parseInt(String(opts.limit ?? '5'), 10) || 5;
const subgroup = opts.subgroup as string | undefined;
const backend = new LocalBackend();
try {
await backend.init();
console.log(`Searching "${queryText}" across group "${name}"...\n`);
const raw = await backend.getGroupService().groupQuery({
name,
query: queryText,
limit,
subgroup,
});
const merged = raw as {
results: Array<Record<string, unknown>>;
per_repo: Array<{ repo: string; count: number }>;
};
if (opts.json) {
console.log(JSON.stringify(raw, null, 2));
} else {
console.log(`Results (top ${merged.results.length}):\n`);
for (const p of merged.results) {
const label = (p.summary || p.heuristicLabel || p.name || 'unnamed') as string;
console.log(` [${p._repo}] ${label} (rrf: ${(p._rrf_score as number).toFixed(4)})`);
}
if (merged.results.length === 0) {
console.log(' No matching execution flows found.');
}
}
} finally {
await backend.dispose().catch(() => {});
}
},
);
group
.command('contracts <name>')
.description('Inspect Contract Registry')
.option('--type <type>', 'Filter by contract type')
.option('--repo <repo>', 'Filter by repo')
.option('--unmatched', 'Show only unmatched contracts')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const backend = new LocalBackend();
try {
await backend.init();
const raw = await backend.getGroupService().groupContracts({
name,
type: opts.type as string | undefined,
repo: opts.repo as string | undefined,
unmatchedOnly: Boolean(opts.unmatched),
});
if (raw && typeof raw === 'object' && 'error' in raw) {
console.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
const { contracts, crossLinks } = raw as {
contracts: Array<{
role: string;
contractId: string;
repo: string;
symbolRef: { name: string };
}>;
crossLinks: Array<{
from: { repo: string };
to: { repo: string };
matchType: string;
confidence: number;
contractId: string;
}>;
};
if (opts.json) {
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
} else {
console.log(`Contracts (${contracts.length}):`);
for (const c of contracts) {
console.log(` [${c.role}] ${c.contractId} (${c.repo}) ${c.symbolRef.name}`);
}
console.log(`\nCross-links (${crossLinks.length}):`);
for (const l of crossLinks) {
console.log(
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
}
} finally {
await backend.dispose().catch(() => {});
}
});
}
+3
View File
@@ -6,6 +6,7 @@
import { Command } from 'commander';
import { createRequire } from 'node:module';
import { createLazyAction } from './lazy-action.js';
import { registerGroupCommands } from './group.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
@@ -148,4 +149,6 @@ program
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
registerGroupCommands(program);
program.parse(process.argv);
+35 -2
View File
@@ -9,7 +9,7 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { execFile } from 'child_process';
import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
@@ -25,11 +25,44 @@ interface SetupResult {
errors: string[];
}
/**
* Resolve the absolute path to the `gitnexus` binary if it's installed
* globally (or via npm -g / yarn global). Returns null when not found.
*/
function resolveGitnexusBin(): string | null {
try {
const cmd = process.platform === 'win32' ? 'where' : 'which';
const resolved = execFileSync(cmd, ['gitnexus'], {
encoding: 'utf-8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
})
.split('\n')[0]
.trim();
return resolved || null;
} catch {
return null;
}
}
/**
* The MCP server entry for all editors.
* On Windows, npx must be invoked via cmd /c since it's a .cmd script.
*
* Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
* `npx -y gitnexus@latest` (cold-cache install of native deps can take
* >60 s, exceeding Claude Code's 30 s MCP connection timeout).
*
* Falls back to npx when the binary isn't on PATH — e.g. first-time
* users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
*/
function getMcpEntry() {
const bin = resolveGitnexusBin();
if (bin) {
return { command: bin, args: ['mcp'] };
}
// Fallback: npx (works without a global install, but slow cold-start)
if (process.platform === 'win32') {
return {
command: 'cmd',
+18 -58
View File
@@ -232,65 +232,28 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
} else if (choice === '3') {
// Azure OpenAI guided setup
console.log('\n Azure OpenAI setup.');
console.log(
' You need: your resource name, deployment name, and API key from the Azure portal.\n',
);
// Azure OpenAI guided setup — minimal prompts
console.log('\n Azure OpenAI setup.\n');
const resourceName = (
await prompt(' Azure resource name (e.g. my-openai-resource): ')
).trim();
if (!resourceName) {
console.log('\n No resource name provided. Aborting.\n');
const endpoint = (
await prompt(' Endpoint URL (e.g. https://my-resource.openai.azure.com): ')
)
.trim()
.replace(/\/+$/, '');
if (!endpoint) {
console.log('\n No endpoint provided. Aborting.\n');
process.exitCode = 1;
return;
}
const deploymentName = (
await prompt(' Deployment name (the name you gave your model deployment): ')
).trim();
const deploymentName = (await prompt(' Deployment name: ')).trim();
if (!deploymentName) {
console.log('\n No deployment name provided. Aborting.\n');
process.exitCode = 1;
return;
}
// Offer v1 or legacy URL
console.log('\n API format:');
console.log(' [1] v1 API — recommended (no api-version needed)');
console.log(' [2] Legacy — uses api-version query param\n');
const apiFormat = await prompt(' Select format (1/2, default: 1): ');
let azureApiVersion: string | undefined;
let azureBaseUrl: string;
if (apiFormat === '2') {
const versionInput = await prompt(' api-version (default: 2024-10-21): ');
azureApiVersion = versionInput || '2024-10-21';
azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/deployments/${deploymentName}`;
} else {
azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/v1`;
azureApiVersion = undefined;
}
defaultModel = deploymentName;
// Ask if this is a reasoning model deployment
const reasoningAnswer = await prompt(
' Is this a reasoning model (o1, o3, o4-mini)? (y/N): ',
);
const isReasoningModelDeployment = ['y', 'yes'].includes(reasoningAnswer.toLowerCase());
if (isReasoningModelDeployment) {
console.log(
' Note: temperature and max_tokens will be omitted for this deployment (Azure reasoning model requirement).\n',
);
}
const modelInput = await prompt(` Model / deployment name (default: ${defaultModel}): `);
const model = modelInput || defaultModel;
// API key
// API key — use env var if available
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
let azureKey: string;
if (envKey) {
@@ -311,26 +274,23 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
return;
}
// Save Azure config including optional apiVersion and isReasoningModel
const azureConfig: Parameters<typeof saveCLIConfig>[0] = {
// Always use v1 API format — no need for api-version
const azureBaseUrl = `${endpoint}/openai/v1`;
await saveCLIConfig({
apiKey: azureKey,
baseUrl: azureBaseUrl,
model,
model: deploymentName,
provider: 'azure',
isReasoningModel: isReasoningModelDeployment,
};
if (azureApiVersion) azureConfig.apiVersion = azureApiVersion;
await saveCLIConfig(azureConfig);
});
console.log(' Config saved to ~/.gitnexus/config.json\n');
llmConfig = {
...llmConfig,
apiKey: azureKey,
baseUrl: azureBaseUrl,
model,
model: deploymentName,
provider: 'azure',
apiVersion: azureApiVersion,
isReasoningModel: isReasoningModelDeployment,
};
} else {
// OpenAI-compatible provider (OpenAI, OpenRouter, Custom)
+1 -1
View File
@@ -93,7 +93,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
if (!repo) return '';
// Lazy-load lbug adapter (skip unnecessary init)
const { initLbug, executeQuery, isLbugReady } = await import('../../mcp/core/lbug-adapter.js');
const { initLbug, executeQuery, isLbugReady } = await import('../lbug/pool-adapter.js');
const { searchFTSFromLbug } = await import('../search/bm25-index.js');
const repoId = repo.name.toLowerCase();
+39
View File
@@ -0,0 +1,39 @@
/**
* Git working tree vs index commit staleness (used by MCP resources, group status, etc.).
* Lives in core/ so application code does not depend on the MCP package layer.
*/
import { execFileSync } from 'node:child_process';
export interface StalenessInfo {
isStale: boolean;
commitsBehind: number;
hint?: string;
}
/**
* Check how many commits the index is behind HEAD (synchronous; uses git CLI).
*/
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
const commitsBehind = parseInt(result, 10) || 0;
if (commitsBehind > 0) {
return {
isStale: true,
commitsBehind,
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
};
}
return { isStale: false, commitsBehind: 0 };
} catch {
return { isStale: false, commitsBehind: 0 };
}
}
+98
View File
@@ -0,0 +1,98 @@
import { createRequire } from 'node:module';
import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from './types.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom'];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
const DEFAULT_DETECT = {
http: true,
grpc: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
};
export function parseGroupConfig(yamlContent: string): GroupConfig {
const raw = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Invalid YAML: expected an object');
}
if (raw.version === undefined) throw new Error('version is required in group.yaml');
if (raw.version !== 1) {
throw new Error(`Unsupported group.yaml version: ${raw.version}. Expected 1.`);
}
if (!raw.name || typeof raw.name !== 'string') throw new Error('name is required in group.yaml');
if (!raw.repos || typeof raw.repos !== 'object' || Array.isArray(raw.repos)) {
throw new Error('repos is required in group.yaml (must be a mapping)');
}
const repos = raw.repos as Record<string, string>;
const repoPaths = new Set(Object.keys(repos));
const rawLinks = (raw.links as unknown[]) || [];
const links: GroupManifestLink[] = rawLinks.map((l: unknown, i: number) => {
const link = l as Record<string, unknown>;
if (!link.from || !repoPaths.has(link.from as string)) {
throw new Error(`links[${i}].from "${link.from}" does not match any repo path in group`);
}
if (!link.to || !repoPaths.has(link.to as string)) {
throw new Error(`links[${i}].to "${link.to}" does not match any repo path in group`);
}
if (!VALID_CONTRACT_TYPES.includes(link.type as ContractType)) {
throw new Error(
`links[${i}].type "${link.type}" is invalid. Expected: ${VALID_CONTRACT_TYPES.join(', ')}`,
);
}
if (!VALID_ROLES.includes(link.role as ContractRole)) {
throw new Error(`links[${i}].role "${link.role}" is invalid. Expected: provider | consumer`);
}
if (
link.contract === undefined ||
link.contract === null ||
String(link.contract).trim() === ''
) {
throw new Error(`links[${i}].contract is required`);
}
return {
from: link.from as string,
to: link.to as string,
type: link.type as ContractType,
contract: String(link.contract),
role: link.role as ContractRole,
};
});
const detect = { ...DEFAULT_DETECT, ...((raw.detect as object) || {}) };
const matching = { ...DEFAULT_MATCHING, ...((raw.matching as object) || {}) };
const packages = (raw.packages as Record<string, Record<string, string>>) || {};
return {
version: 1,
name: raw.name as string,
description: (raw.description as string) || '',
repos,
links,
packages,
detect,
matching,
};
}
export async function loadGroupConfig(groupDir: string): Promise<GroupConfig> {
const fsp = await import('node:fs/promises');
const path = await import('node:path');
const yamlPath = path.join(groupDir, 'group.yaml');
const content = await fsp.readFile(yamlPath, 'utf-8');
return parseGroupConfig(content);
}
@@ -0,0 +1,16 @@
import type { ContractType, ExtractedContract, RepoHandle } from './types.js';
export interface ContractExtractor {
type: ContractType;
canExtract(repo: RepoHandle): Promise<boolean>;
extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]>;
}
export type CypherExecutor = (
query: string,
params?: Record<string, unknown>,
) => Promise<Record<string, unknown>[]>;
@@ -0,0 +1,357 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function contractId(pkg: string, service: string, method: string): string {
const prefix = pkg ? `${pkg}.${service}` : service;
return `grpc::${prefix}/${method}`;
}
function serviceOnlyContractId(serviceName: string): string {
return `grpc::${serviceName}/*`;
}
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
// v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2)
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerRe.exec(content)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
while (pos < content.length && depth > 0) {
const ch = content[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
}
// If EOF before depth returns to 0, skip incomplete service
if (depth !== 0) continue;
// body is between opening { (consumed by regex) and closing } (pos is one past it)
const body = content.slice(bodyStart, pos - 1);
results.push({ name: serviceName, body });
}
return results;
}
function makeContract(
cid: string,
role: 'provider' | 'consumer',
filePath: string,
symbolName: string,
confidence: number,
meta: Record<string, unknown>,
): ExtractedContract {
return {
contractId: cid,
type: 'grpc',
role,
symbolUid: '',
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
symbolName,
confidence,
meta: { ...meta, extractionStrategy: 'source_scan' },
};
}
export class GrpcExtractor implements ContractExtractor {
type = 'grpc' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
// Proto files — definitive provider source
const protoFiles = await glob('**/*.proto', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
nodir: true,
});
for (const rel of protoFiles) {
const content = readSafe(repoPath, rel);
if (content) out.push(...this.parseProtoFile(content, rel));
}
// Source files — server/client detection
const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
nodir: true,
});
for (const rel of sourceFiles) {
const content = readSafe(repoPath, rel);
if (!content) continue;
const ext = path.extname(rel).toLowerCase();
if (ext === '.go') {
out.push(...this.scanGoProviders(content, rel));
out.push(...this.scanGoConsumers(content, rel));
} else if (ext === '.java') {
out.push(...this.scanJavaProviders(content, rel));
out.push(...this.scanJavaConsumers(content, rel));
} else if (ext === '.py') {
out.push(...this.scanPythonProviders(content, rel));
out.push(...this.scanPythonConsumers(content, rel));
} else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
out.push(...this.scanTsProviders(content, rel));
}
}
return this.dedupe(out);
}
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch ? pkgMatch[1] : '';
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRe.exec(body)) !== null) {
const methodName = rpcMatch[1];
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
package: pkg,
service: serviceName,
method: methodName,
source: 'proto',
}),
);
}
}
return out;
}
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// pb.RegisterXxxServer(
const registerRe = /\w+\.Register(\w+)Server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = registerRe.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Register${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_register' },
),
);
}
// pb.UnimplementedXxxServer
const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g;
while ((m = unimplRe.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Unimplemented${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_unimplemented' },
),
);
}
return out;
}
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /\w+\.New(\w+)Client\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`New${serviceName}Client`,
0.7,
{ service: serviceName, source: 'go_client' },
),
);
}
return out;
}
private scanJavaProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcService
if (content.includes('@GrpcService')) {
const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/;
const m = content.match(implBaseRe);
if (m) {
out.push(
makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, {
service: m[1],
source: 'java_grpc_service',
}),
);
} else {
// Try extracting service name from class name
const classRe =
/class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/;
const cm = content.match(classRe);
if (cm) {
const svcName = cm[2].replace(/Grpc$/, '');
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, {
service: svcName,
source: 'java_grpc_service',
}),
);
}
}
}
// extends XxxImplBase (without @GrpcService)
if (!content.includes('@GrpcService')) {
const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/;
const m = content.match(implRe);
if (m) {
const svcName = m[2] || m[1].replace(/Grpc$/, '');
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, {
service: svcName,
source: 'java_impl_base',
}),
);
}
}
return out;
}
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxGrpc.newBlockingStub( or XxxGrpc.newStub(
const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`${serviceName}Stub`,
0.7,
{ service: serviceName, source: 'java_stub' },
),
);
}
return out;
}
private scanPythonProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// add_XxxServicer_to_server(
const re = /add_(\w+?)Servicer_to_server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`add_${serviceName}Servicer_to_server`,
0.8,
{ service: serviceName, source: 'python_servicer' },
),
);
}
return out;
}
private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxStub(
const re = /(\w+)Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const name = m[1];
// Filter out common false positives
if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue;
out.push(
makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, {
service: name,
source: 'python_stub',
}),
);
}
return out;
}
private scanTsProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcMethod('ServiceName', 'MethodName')
const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const methodName = m[2];
const cid = contractId('', serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, {
service: serviceName,
method: methodName,
source: 'ts_grpc_method',
}),
);
}
return out;
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}
@@ -0,0 +1,475 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
const HANDLES_ROUTE_QUERY = `
MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
route.responseKeys AS responseKeys,
r.reason AS routeSource`;
const FETCHES_QUERY = `
MATCH (callerFile:File)-[r:CodeRelation {type: 'FETCHES'}]->(route:Route)
RETURN callerFile.id AS fileId, callerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
r.reason AS fetchReason`;
const CONTAINS_QUERY = `
MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym)
WHERE sym.startLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
ORDER BY sym.startLine`;
export function normalizeHttpPath(p: string): string {
let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, '');
s = s.replace(/:\w+/g, '{param}');
s = s.replace(/\{[^}]+\}/g, '{param}');
s = s.replace(/\[[^\]]+\]/g, '{param}');
return s;
}
function methodFromRouteReason(reason: string): string | null {
const r = reason || '';
if (/GetMapping|decorator-Get/i.test(r)) return 'GET';
if (/PostMapping|decorator-Post/i.test(r)) return 'POST';
if (/PutMapping|decorator-Put/i.test(r)) return 'PUT';
if (/DeleteMapping|decorator-Delete/i.test(r)) return 'DELETE';
if (/PatchMapping|decorator-Patch/i.test(r)) return 'PATCH';
return null;
}
function contractIdFor(method: string, pathNorm: string): string {
return `http::${method.toUpperCase()}::${pathNorm}`;
}
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function pickJavaHandlerName(
content: string,
routePath: string,
httpMethod: string,
): string | null {
const tail = routePath.split('/').filter(Boolean).pop() || '';
const mapNames: Record<string, string> = {
GET: 'GetMapping',
POST: 'PostMapping',
PUT: 'PutMapping',
DELETE: 'DeleteMapping',
PATCH: 'PatchMapping',
};
const ann = mapNames[httpMethod] || 'GetMapping';
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes(`@${ann}`)) continue;
if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail))
continue;
for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
if (m) return m[1];
}
}
return null;
}
function pickSymbolUid(
rows: Record<string, unknown>[],
preferredName: string | null,
): { uid: string; name: string; filePath: string } {
const norm = (x: unknown) => String(x ?? '');
const labeled = rows.filter((r) => {
const labels = r.labels ?? r[3];
const s = JSON.stringify(labels);
return s.includes('Method') || s.includes('Function');
});
const pool = labeled.length > 0 ? labeled : rows;
if (preferredName) {
const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName);
if (hit) {
return {
uid: norm(hit.uid ?? hit[0]),
name: norm(hit.name ?? hit[1]),
filePath: norm(hit.filePath ?? hit[2]),
};
}
}
const first = pool[0] || rows[0];
return {
uid: norm(first?.uid ?? first?.[0]),
name: norm(first?.name ?? first?.[1]),
filePath: norm(first?.filePath ?? first?.[2]),
};
}
export class HttpRouteExtractor implements ContractExtractor {
type = 'http' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]> {
const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : [];
const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath);
const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : [];
const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath);
return [...providers, ...consumers];
}
private async extractProvidersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
try {
rows = await db(HANDLES_ROUTE_QUERY);
} catch {
return [];
}
for (const row of rows) {
const filePath = String(row.filePath ?? '');
const routePath = String(row.routePath ?? '');
const routeSource = String(row.routeSource ?? row.routeReason ?? '');
let method = methodFromRouteReason(routeSource);
const content = readSafe(repoPath, filePath);
if (!method && content) {
method = this.inferMethodFromFileScan(content, routePath, 'provider');
}
if (!method) method = 'GET';
const pathNorm = normalizeHttpPath(routePath);
const cid = contractIdFor(method, pathNorm);
const handlerName =
content && routePath ? pickJavaHandlerName(content, routePath, method) : null;
let symbolUid = '';
let symbolName = path.basename(filePath) || 'handler';
let symPath = filePath;
const fileId = row.fileId ?? row[0];
if (fileId) {
try {
const syms = await db(CONTAINS_QUERY, { fileId });
if (syms.length > 0) {
const picked = pickSymbolUid(syms, handlerName);
symbolUid = picked.uid;
symbolName = picked.name;
symPath = picked.filePath || filePath;
}
} catch {
/* ignore */
}
}
out.push({
contractId: cid,
type: 'http',
role: 'provider',
symbolUid,
symbolRef: { filePath: symPath, name: symbolName },
symbolName,
confidence: 0.9,
meta: {
method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'graph_assisted',
routeSource,
},
});
}
return out;
}
private inferMethodFromFileScan(
content: string,
routePath: string,
_role: string,
): string | null {
const tail = routePath.split('/').filter(Boolean).pop() || '';
for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) {
const mapNames: Record<string, string> = {
GET: 'GetMapping',
POST: 'PostMapping',
PUT: 'PutMapping',
DELETE: 'DeleteMapping',
PATCH: 'PatchMapping',
};
if (
content.includes(`@${mapNames[m]}`) &&
(content.includes(tail) || routePath.includes(tail))
) {
return m;
}
}
return null;
}
private async extractProvidersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanSpringProviders(content, rel));
out.push(...this.scanExpressProviders(content, rel));
out.push(...this.scanLaravelProviders(content, rel));
out.push(...this.scanFastApiProviders(content, rel));
}
return this.dedupeContracts(out);
}
private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
private scanSpringProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
let classPrefix = '';
const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/);
if (classRm) classPrefix = classRm[1].replace(/\/+$/, '');
const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
let p = m[2];
if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`;
const pathNorm = normalizeHttpPath(p);
const sub = content.slice(m.index);
const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
const name = nameM ? nameM[1] : m[0];
out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8));
}
return out;
}
private scanExpressProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
}
return out;
}
private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8));
}
return out;
}
private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
}
return out;
}
private makeProvider(
filePath: string,
method: string,
pathNorm: string,
name: string,
confidence: number,
): ExtractedContract {
const cid = contractIdFor(method, pathNorm);
return {
contractId: cid,
type: 'http',
role: 'provider',
symbolUid: '',
symbolRef: { filePath, name },
symbolName: name,
confidence,
meta: {
method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'source_scan',
},
};
}
private async extractConsumersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
try {
rows = await db(FETCHES_QUERY);
} catch {
return [];
}
for (const row of rows) {
const filePath = String(row.filePath ?? '');
const routePath = String(row.routePath ?? '');
const pathNorm = normalizeHttpPath(routePath);
let method = 'GET';
const content = readSafe(repoPath, filePath);
if (content) {
const inferred = this.inferFetchMethod(content, pathNorm);
if (inferred) method = inferred;
}
const cid = contractIdFor(method, pathNorm);
let symbolUid = '';
let symbolName = 'fetch';
let symPath = filePath;
const fileId = row.fileId ?? row[0];
if (fileId) {
try {
const syms = await db(CONTAINS_QUERY, { fileId });
if (syms.length > 0) {
const picked = pickSymbolUid(syms, null);
symbolUid = picked.uid;
symbolName = picked.name;
symPath = picked.filePath || filePath;
}
} catch {
/* ignore */
}
}
out.push({
contractId: cid,
type: 'http',
role: 'consumer',
symbolUid,
symbolRef: { filePath: symPath, name: symbolName },
symbolName,
confidence: 0.9,
meta: {
method,
path: pathNorm,
extractionStrategy: 'graph_assisted',
fetchReason: String(row.fetchReason ?? ''),
},
});
}
return out;
}
private inferFetchMethod(content: string, pathNorm: string): string | null {
const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fetchRe = new RegExp(
`fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`,
'i',
);
const m = content.match(fetchRe);
if (m) return m[2].toUpperCase();
return null;
}
private async extractConsumersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**'],
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanFetchConsumers(content, rel));
out.push(...this.scanAxiosConsumers(content, rel));
}
return this.dedupeContracts(out);
}
private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re =
/fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const pathNorm = normalizeHttpPath(this.templateToPattern(m[1]));
const method = (m[2] || 'GET').toUpperCase();
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
}
return out;
}
private templateToPattern(url: string): string {
return url.replace(/\$\{[^}]+\}/g, '{param}');
}
private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(this.templateToPattern(m[2]));
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
}
return out;
}
private makeConsumer(
filePath: string,
method: string,
pathNorm: string,
confidence: number,
): ExtractedContract {
return {
contractId: contractIdFor(method, pathNorm),
type: 'http',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath, name: 'fetch' },
symbolName: 'fetch',
confidence,
meta: {
method,
path: pathNorm,
extractionStrategy: 'source_scan',
},
};
}
}
@@ -0,0 +1,277 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
type Broker = 'kafka' | 'rabbitmq' | 'nats';
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function makeContract(
topicName: string,
role: 'provider' | 'consumer',
filePath: string,
symbolName: string,
confidence: number,
broker: Broker,
): ExtractedContract {
return {
contractId: `topic::${topicName}`,
type: 'topic',
role,
symbolUid: '',
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
symbolName,
confidence,
meta: {
broker,
topicName,
extractionStrategy: 'source_scan',
},
};
}
interface PatternDef {
regex: RegExp;
role: 'provider' | 'consumer';
broker: Broker;
confidence: number;
topicGroup: number;
symbolName: string;
}
// --- Kafka patterns ---
const KAFKA_PATTERNS: PatternDef[] = [
// Java: @KafkaListener(topics = "xxx")
{
regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'kafkaListener',
},
// Java: kafkaTemplate.send("xxx"
{
regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi,
role: 'provider',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'kafkaTemplate.send',
},
// Node: producer.send({ topic: 'xxx'
{
regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'producer.send',
},
// Node: consumer.subscribe({ topic: 'xxx'
{
regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'consumer.subscribe',
},
// Go: consumer.ConsumePartition("xxx"
{
regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'ConsumePartition',
},
// Python: KafkaConsumer('xxx'
{
regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'KafkaConsumer',
},
// Python: producer.send('xxx' or producer.produce('xxx'
{
regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'producer.send',
},
];
// --- RabbitMQ patterns ---
const RABBITMQ_PATTERNS: PatternDef[] = [
// Java: @RabbitListener(queues = "xxx")
{
regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'rabbitListener',
},
// Java: rabbitTemplate.convertAndSend("xxx"
{
regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'rabbitTemplate.convertAndSend',
},
// Node: channel.consume("xxx"
{
regex: /channel\.consume\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.consume',
},
// Node: channel.publish("xxx"
{
regex: /channel\.publish\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.publish',
},
// Node: channel.sendToQueue("xxx"
{
regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.sendToQueue',
},
// Python: channel.basic_consume(queue='xxx'
{
regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.7,
topicGroup: 1,
symbolName: 'basic_consume',
},
// Python: channel.basic_publish(exchange='xxx'
{
regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.7,
topicGroup: 1,
symbolName: 'basic_publish',
},
];
// --- NATS patterns ---
const NATS_PATTERNS: PatternDef[] = [
// Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx"
{
regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'nats',
confidence: 0.8,
topicGroup: 1,
symbolName: 'nc.Subscribe',
},
// Go/Node: nc.Publish("xxx" or nc.publish("xxx"
{
regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'nats',
confidence: 0.8,
topicGroup: 1,
symbolName: 'nc.Publish',
},
];
const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS];
export class TopicExtractor implements ContractExtractor {
type = 'topic' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanFile(content, rel));
}
return this.dedupe(out);
}
private scanFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
for (const pattern of ALL_PATTERNS) {
// Reset regex state for each file
const re = new RegExp(pattern.regex.source, pattern.regex.flags);
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const topicName = m[pattern.topicGroup];
if (!topicName) continue;
out.push(
makeContract(
topicName,
pattern.role,
filePath,
pattern.symbolName,
pattern.confidence,
pattern.broker,
),
);
}
}
return out;
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}
+127
View File
@@ -0,0 +1,127 @@
import type { StoredContract, CrossLink } from './types.js';
export interface MatchResult {
matched: CrossLink[];
unmatched: StoredContract[];
}
export function normalizeContractId(id: string): string {
const colonIdx = id.indexOf('::');
if (colonIdx === -1) return id;
const type = id.substring(0, colonIdx);
const rest = id.substring(colonIdx + 2);
switch (type) {
case 'http': {
const parts = rest.split('::');
if (parts.length >= 2) {
const method = parts[0].toUpperCase();
let pathPart = parts.slice(1).join('::');
pathPart = pathPart.replace(/\/+$/, '');
return `http::${method}::${pathPart}`;
}
return id;
}
case 'grpc': {
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const pkg = rest.substring(0, slashIdx).toLowerCase();
const method = rest.substring(slashIdx);
return `grpc::${pkg}${method}`;
}
if (slashIdx === 0) {
// Malformed "package/method" with leading slash — do not lowercase the whole string
// (method segment is case-sensitive per spec).
return `grpc::${rest}`;
}
// No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase
// the whole token; differs from pkg/method split above where RPC method keeps case.
return `grpc::${rest.toLowerCase()}`;
}
case 'topic':
return `topic::${rest.trim().toLowerCase()}`;
case 'lib':
return `lib::${rest.toLowerCase()}`;
default:
return id;
}
}
function findMatchingKeys(contractId: string, index: Map<string, StoredContract[]>): string[] {
const normalized = normalizeContractId(contractId);
if (index.has(normalized)) return [normalized];
if (normalized.startsWith('http::*::')) {
const pathPart = normalized.substring('http::*::'.length);
const matches: string[] = [];
for (const key of index.keys()) {
if (key.startsWith('http::') && key.endsWith(`::${pathPart}`)) {
matches.push(key);
}
}
return matches;
}
return [];
}
export function runExactMatch(contracts: StoredContract[]): MatchResult {
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
const providerIndex = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
const list = providerIndex.get(key) || [];
list.push(p);
providerIndex.set(key, list);
}
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
const matchedProviderIds = new Set<string>();
for (const consumer of consumers) {
const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex);
if (matchingKeys.length === 0) continue;
const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []);
for (const provider of allMatchingProviders) {
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId,
matchType: 'exact',
confidence: 1.0,
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
matchedProviderIds.add(`${provider.repo}::${provider.contractId}`);
}
}
const unmatched = contracts.filter((c) => {
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
return { matched, unmatched };
}
@@ -0,0 +1,177 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export interface ServiceBoundary {
servicePath: string;
serviceName: string;
markers: string[];
confidence: number;
}
const SERVICE_MARKERS = [
'package.json',
'go.mod',
'Dockerfile',
'pom.xml',
'build.gradle',
'build.gradle.kts',
'Cargo.toml',
'pyproject.toml',
'requirements.txt',
'mix.exs',
] as const;
const SOURCE_EXTENSIONS = new Set([
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.go',
'.java',
'.kt',
'.kts',
'.py',
'.pyi',
'.rs',
'.c',
'.cpp',
'.h',
'.hpp',
'.cs',
'.rb',
'.php',
'.swift',
'.dart',
'.ex',
'.exs',
'.erl',
'.proto',
]);
const EXCLUDED_DIRS = new Set([
'node_modules',
'vendor',
'target',
'build',
'dist',
'__pycache__',
'.venv',
'venv',
'.tox',
'.mypy_cache',
'.gradle',
'.mvn',
'out',
'bin',
]);
export async function detectServiceBoundaries(repoPath: string): Promise<ServiceBoundary[]> {
const boundaries: ServiceBoundary[] = [];
await walkForBoundaries(repoPath, repoPath, boundaries);
return boundaries;
}
async function walkForBoundaries(
dir: string,
repoRoot: string,
results: ServiceBoundary[],
): Promise<void> {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
const isRoot = path.resolve(dir) === path.resolve(repoRoot);
const foundMarkers: string[] = [];
let hasSourceFiles = false;
const subdirs: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
if (entry.isDirectory()) {
subdirs.push(path.join(dir, entry.name));
} else if (entry.isFile()) {
if (SERVICE_MARKERS.includes(entry.name as (typeof SERVICE_MARKERS)[number])) {
foundMarkers.push(entry.name);
}
const ext = path.extname(entry.name).toLowerCase();
if (SOURCE_EXTENSIONS.has(ext)) {
hasSourceFiles = true;
}
}
}
// Check subdirectories for source files if not found at this level
if (!hasSourceFiles && foundMarkers.length > 0) {
hasSourceFiles = await hasSourceFilesInSubdirs(subdirs);
}
if (!isRoot && foundMarkers.length >= 1 && hasSourceFiles) {
const relativePath = path.relative(repoRoot, dir).replace(/\\/g, '/');
const serviceName = path.basename(dir);
const confidence = computeConfidence(foundMarkers.length);
results.push({
servicePath: relativePath,
serviceName,
markers: foundMarkers,
confidence,
});
}
// Recurse into subdirectories
for (const subdir of subdirs) {
await walkForBoundaries(subdir, repoRoot, results);
}
}
async function hasSourceFilesInSubdirs(subdirs: string[]): Promise<boolean> {
for (const subdir of subdirs) {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(subdir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (SOURCE_EXTENSIONS.has(ext)) return true;
}
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
const deeper = await hasSourceFilesInSubdirs([path.join(subdir, entry.name)]);
if (deeper) return true;
}
}
}
return false;
}
function computeConfidence(markerCount: number): number {
if (markerCount >= 3) return 1.0;
if (markerCount === 2) return 0.9;
return 0.75;
}
export function assignService(filePath: string, boundaries: ServiceBoundary[]): string | undefined {
const normalized = filePath.replace(/\\/g, '/');
let bestMatch: ServiceBoundary | undefined;
let bestLength = 0;
for (const boundary of boundaries) {
const prefix = boundary.servicePath + '/';
if (normalized.startsWith(prefix) && boundary.servicePath.length > bestLength) {
bestMatch = boundary;
bestLength = boundary.servicePath.length;
}
}
return bestMatch?.servicePath;
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Group orchestration shared by MCP (LocalBackend) and CLI.
* DB access is injected via GroupToolPort so this module stays free of LocalBackend private API.
*/
import { checkStaleness } from '../git-staleness.js';
import { loadGroupConfig } from './config-parser.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
import { syncGroup } from './sync.js';
export interface GroupRepoHandle {
id: string;
name: string;
repoPath: string;
storagePath: string;
indexedAt?: string;
lastCommit?: string;
}
export interface GroupToolPort {
resolveRepo(repoParam?: string): Promise<GroupRepoHandle>;
impact(
repo: GroupRepoHandle,
params: {
target: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
},
): Promise<unknown>;
query(
repo: GroupRepoHandle,
params: {
query: string;
task_context?: string;
goal?: string;
limit?: number;
max_symbols?: number;
include_content?: boolean;
},
): Promise<unknown>;
impactByUid(
repoId: string,
uid: string,
direction: string,
opts: {
maxDepth: number;
relationTypes: string[];
minConfidence: number;
includeTests: boolean;
},
): Promise<unknown | null>;
}
function repoInSubgroup(repoPath: string, subgroup?: string): boolean {
if (!subgroup?.trim()) return true;
const s = subgroup.replace(/\/+$/, '');
return repoPath === s || repoPath.startsWith(`${s}/`);
}
export class GroupService {
constructor(private readonly port: GroupToolPort) {}
async groupList(params: Record<string, unknown>): Promise<unknown> {
const name = typeof params.name === 'string' ? params.name.trim() : '';
if (!name) {
const groups = await listGroups();
return { groups };
}
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
return {
name: config.name,
description: config.description,
repos: config.repos,
links: config.links,
};
}
async groupSync(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
skipEmbeddings: Boolean(params.skipEmbeddings),
allowStale: Boolean(params.allowStale),
verbose: Boolean(params.verbose),
});
return {
contracts: result.contracts.length,
crossLinks: result.crossLinks.length,
unmatched: result.unmatched.length,
missingRepos: result.missingRepos,
};
}
async groupContracts(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
if (!registry) {
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
}
let contracts = registry.contracts;
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
if (params.unmatchedOnly) {
const matchedIds = new Set(
registry.crossLinks.flatMap((l) => [
`${l.from.repo}::${l.contractId}`,
`${l.to.repo}::${l.contractId}`,
]),
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
return { contracts, crossLinks: registry.crossLinks };
}
async groupQuery(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
const queryText = String(params.query ?? '').trim();
if (!name || !queryText) return { error: 'name and query are required' };
const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5;
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const perRepo: Array<{ repo: string; score: number; processes: unknown[] }> = [];
for (const [repoPath, registryName] of Object.entries(config.repos)) {
if (!repoInSubgroup(repoPath, subgroup)) continue;
try {
const repoObj = await this.port.resolveRepo(registryName);
const queryResult = (await this.port.query(repoObj, {
query: queryText,
limit,
max_symbols: 10,
include_content: false,
})) as { processes?: Array<Record<string, unknown>> };
const processes = queryResult.processes || [];
const scored = processes.map((p, idx) => ({
...p,
_rrf_score: 1 / (idx + 1 + 60),
_repo: repoPath,
}));
perRepo.push({ repo: repoPath, score: 0, processes: scored });
} catch {
perRepo.push({ repo: repoPath, score: 0, processes: [] });
}
}
const allProcesses = perRepo.flatMap((r) => r.processes as Array<Record<string, unknown>>);
allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number));
const topN = allProcesses.slice(0, limit);
return {
group: name,
query: queryText,
results: topN,
per_repo: perRepo.map((r) => ({ repo: r.repo, count: r.processes.length })),
};
}
async groupStatus(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const registry = await readContractRegistry(groupDir);
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
> = {};
const fsp = await import('node:fs/promises');
const pathMod = await import('node:path');
for (const [repoPath, registryName] of Object.entries(config.repos)) {
try {
const repoObj = await this.port.resolveRepo(registryName);
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
const staleness = meta.lastCommit
? checkStaleness(repoObj.repoPath, meta.lastCommit)
: { isStale: true, commitsBehind: -1 };
const snapshot = registry?.repoSnapshots[repoPath];
const contractsStale =
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
repoStatuses[repoPath] = {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
}
}
return {
group: name,
lastSync: registry?.generatedAt || null,
missingRepos: registry?.missingRepos || [],
repos: repoStatuses,
};
}
}
+109
View File
@@ -0,0 +1,109 @@
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
const CONTRACTS_FILE = 'contracts.json';
export function getDefaultGitnexusDir(): string {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
}
export function getGroupsBaseDir(gitnexusDir?: string): string {
return path.join(gitnexusDir || getDefaultGitnexusDir(), 'groups');
}
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
export function validateGroupName(name: string): void {
if (!GROUP_NAME_RE.test(name)) {
throw new Error(
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
);
}
}
export function getGroupDir(gitnexusDir: string, groupName: string): string {
validateGroupName(groupName);
return path.join(gitnexusDir, 'groups', groupName);
}
export async function writeContractRegistry(
groupDir: string,
registry: ContractRegistry,
): Promise<void> {
const targetPath = path.join(groupDir, CONTRACTS_FILE);
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
await fsp.rename(tmpPath, targetPath);
}
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
const filePath = path.join(groupDir, CONTRACTS_FILE);
try {
const content = await fsp.readFile(filePath, 'utf-8');
return JSON.parse(content) as ContractRegistry;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
export async function listGroups(gitnexusDir?: string): Promise<string[]> {
const groupsDir = getGroupsBaseDir(gitnexusDir);
try {
const entries = await fsp.readdir(groupsDir, { withFileTypes: true });
const names: string[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const yamlPath = path.join(groupsDir, entry.name, 'group.yaml');
if (fs.existsSync(yamlPath)) {
names.push(entry.name);
}
}
}
return names;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
}
export async function createGroupDir(
gitnexusDir: string,
groupName: string,
force: boolean = false,
): Promise<string> {
const groupDir = getGroupDir(gitnexusDir, groupName);
if (fs.existsSync(path.join(groupDir, 'group.yaml')) && !force) {
throw new Error(`Group "${groupName}" already exists. Use --force to overwrite.`);
}
await fsp.mkdir(groupDir, { recursive: true });
const template = `version: 1
name: ${groupName}
description: ""
repos: {}
links: []
packages: {}
detect:
http: true
grpc: true
topics: true
shared_libs: true
embedding_fallback: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
return groupDir;
}
+185
View File
@@ -0,0 +1,185 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { initLbug, closeLbug, executeParameterized } from '../lbug/pool-adapter.js';
import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js';
import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js';
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import type { ContractRegistry } from './types.js';
export interface SyncOptions {
extractorOverride?:
| ((repo: RepoHandle) => Promise<StoredContract[]>)
| (() => Promise<StoredContract[]>);
resolveRepoHandle?: (registryName: string, groupPath: string) => Promise<RepoHandle | null>;
skipWrite?: boolean;
groupDir?: string;
allowStale?: boolean;
verbose?: boolean;
exactOnly?: boolean;
skipEmbeddings?: boolean;
}
export interface SyncResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
unmatched: StoredContract[];
missingRepos: string[];
repoSnapshots: Record<string, RepoSnapshot>;
}
export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry[]): string {
const base = entry.name.toLowerCase();
const resolved = path.resolve(entry.path);
for (const other of allEntries) {
if (other.name.toLowerCase() === base && path.resolve(other.path) !== resolved) {
const hash = Buffer.from(entry.path).toString('base64url').slice(0, 6);
return `${base}-${hash}`;
}
}
return base;
}
function defaultResolveHandle(allEntries: RegistryEntry[]) {
return async (registryName: string, groupPath: string): Promise<RepoHandle | null> => {
const e = allEntries.find((en) => en.name === registryName);
if (!e) return null;
const poolId = stableRepoPoolId(e, allEntries);
return {
id: poolId,
path: groupPath,
repoPath: e.path,
storagePath: e.storagePath,
};
};
}
export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise<SyncResult> {
const missingRepos: string[] = [];
const repoSnapshots: Record<string, RepoSnapshot> = {};
let autoContracts: StoredContract[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
const entries = await readRegistry();
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
const topicEx = new TopicExtractor();
dbExecutors = new Map<string, CypherExecutor>();
const openPoolIds: string[] = [];
try {
for (const [groupPath, regName] of Object.entries(config.repos)) {
const handle = await resolve(regName, groupPath);
if (!handle) {
missingRepos.push(groupPath);
continue;
}
const poolId = handle.id;
const lbugPath = path.join(handle.storagePath, 'lbug');
try {
await initLbug(poolId, lbugPath);
openPoolIds.push(poolId);
const executor: CypherExecutor = (query, params) =>
executeParameterized(poolId, query, params ?? {});
dbExecutors.set(groupPath, executor);
const boundaries = await detectServiceBoundaries(handle.repoPath);
if (config.detect.http) {
const extracted = await httpEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.grpc) {
const extracted = await grpcEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.topics) {
const extracted = await topicEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
const metaPath = path.join(handle.storagePath, 'meta.json');
try {
const raw = await fs.readFile(metaPath, 'utf-8');
const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string };
repoSnapshots[groupPath] = {
indexedAt: m.indexedAt || '',
lastCommit: m.lastCommit || '',
};
} catch {
const e = entries.find((en) => en.name === regName);
repoSnapshots[groupPath] = {
indexedAt: e?.indexedAt || '',
lastCommit: e?.lastCommit || '',
};
}
} catch {
missingRepos.push(groupPath);
}
}
} finally {
for (const id of [...new Set(openPoolIds)]) {
await closeLbug(id).catch(() => {});
}
}
}
const { matched, unmatched } = runExactMatch(autoContracts);
const crossLinks: CrossLink[] = matched;
const allContracts: StoredContract[] = autoContracts;
const registry: ContractRegistry = {
version: 1,
generatedAt: new Date().toISOString(),
repoSnapshots,
missingRepos,
contracts: allContracts,
crossLinks,
};
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
}
return {
contracts: allContracts,
crossLinks,
unmatched,
missingRepos,
repoSnapshots,
};
}
+133
View File
@@ -0,0 +1,133 @@
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
export interface GroupConfig {
version: number;
name: string;
description: string;
repos: Record<string, string>;
links: GroupManifestLink[];
packages: Record<string, Record<string, string>>;
detect: DetectConfig;
matching: MatchingConfig;
}
export interface GroupManifestLink {
from: string;
to: string;
type: ContractType;
contract: string;
role: ContractRole;
}
export interface DetectConfig {
http: boolean;
grpc: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
}
export interface SymbolRef {
filePath: string;
name: string;
}
export interface ExtractedContract {
contractId: string;
type: ContractType;
role: ContractRole;
symbolUid: string;
symbolRef: SymbolRef;
symbolName: string;
confidence: number;
meta: Record<string, unknown>;
/** Service boundary within a monorepo (relative path from repo root, e.g. "services/auth"). */
service?: string;
}
export interface CrossLinkEndpoint {
repo: string;
/** Service boundary within a monorepo (relative path from repo root). */
service?: string;
symbolUid: string;
symbolRef: SymbolRef;
}
export interface CrossLink {
from: CrossLinkEndpoint;
to: CrossLinkEndpoint;
type: ContractType;
contractId: string;
matchType: MatchType;
confidence: number;
}
export interface RepoSnapshot {
indexedAt: string;
lastCommit: string;
}
export interface ContractRegistry {
version: number;
generatedAt: string;
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
contracts: StoredContract[];
crossLinks: CrossLink[];
}
export interface StoredContract extends ExtractedContract {
repo: string;
}
/** Repo within a group (group path + paths; name collision with MCP RepoHandle — import from group/types only). */
export interface RepoHandle {
id: string;
path: string;
repoPath: string;
storagePath: string;
}
export interface GroupImpactResult {
local: unknown;
group: string;
cross: CrossRepoImpact[];
outOfScope: OutOfScopeLink[];
truncated: boolean;
truncatedRepos: string[];
summary: {
direct: number;
processes_affected: number;
modules_affected: number;
cross_repo_hits: number;
};
risk: string;
}
export interface CrossRepoImpact {
repo: string;
repo_path: string;
contract: {
id: string;
type: ContractType;
match_type: MatchType;
confidence: number;
};
by_depth: Record<string, unknown[]>;
affected_processes: string[];
}
export interface OutOfScopeLink {
from: string;
to: string;
contractId: string;
confidence: number;
}
+224 -14
View File
@@ -7,13 +7,15 @@ import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import {
FUNCTION_NODE_TYPES,
extractFunctionName,
findEnclosingClassId,
findEnclosingClassInfo,
genericFuncName,
inferFunctionLabel,
} from './utils/ast-helpers.js';
import {
countCallArguments,
@@ -37,9 +39,11 @@ import type {
FileConstructorBindings,
} from './workers/parse-worker.js';
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
import { extractTemplateComponents } from './vue-sfc-extractor.js';
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { extractParsedCallSite } from './call-sites/extract-language-call-site.js';
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
@@ -138,8 +142,10 @@ export function buildExportedTypeMapFromGraph(
const filePath = node.properties.filePath as string;
const name = node.properties.name as string;
if (!name || name.length > MAX_TYPE_NAME_LENGTH) return;
// For callable symbols, use returnType; for properties/variables, use declaredType
const def = symbolTable.lookupExactFull(filePath, name);
// For callable symbols, use returnType; for properties/variables, use declaredType.
// Use lookupExactAll + nodeId match to handle same-name methods in different classes.
const defs = symbolTable.lookupExactAll(filePath, name);
const def = defs.find((d) => d.nodeId === node.id) ?? defs[0];
if (!def) return;
const typeName = def.returnType ?? def.declaredType;
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) return;
@@ -229,21 +235,52 @@ const findEnclosingFunction = (
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const { funcName, label } = extractFunctionName(current);
const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
const funcName = efnResult?.funcName ?? genericFuncName(current);
const label = efnResult?.label ?? inferFunctionLabel(current.type);
if (funcName) {
const resolved = ctx.resolve(funcName, filePath);
if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
// Disambiguate by enclosing class when multiple candidates
if (resolved.candidates.length === 1) {
return resolved.candidates[0].nodeId;
}
const classInfo = findEnclosingClassInfo(current, filePath);
if (classInfo) {
const match = resolved.candidates.find((c) => c.ownerId === classInfo.classId);
if (match) return match.nodeId;
}
if (process.env.NODE_ENV === 'development' && classInfo) {
console.warn(
`[CallProcessor] Enclosing class '${classInfo.className}' found but no candidate matched — falling back to ${resolved.candidates[0].nodeId}`,
);
}
return resolved.candidates[0].nodeId;
}
// Apply labelOverride so label matches the definition phase (single source of truth).
// Fallback: qualify the generated ID to match definition-phase node IDs
let finalLabel = label;
if (provider.labelOverride) {
const override = provider.labelOverride(current, label);
if (override !== null) finalLabel = override;
}
return generateId(finalLabel, `${filePath}:${funcName}`);
const classInfo = findEnclosingClassInfo(current, filePath);
const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// Use provider.methodExtractor.extractFromNode — same extractor as definition phase.
let arity: number | undefined;
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
const language = getLanguageFromFilename(filePath);
const info = language
? provider.methodExtractor?.extractFromNode?.(current, { filePath, language })
: undefined;
if (info) {
arity = info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length;
}
}
const arityTag = arity !== undefined ? `#${arity}` : '';
return generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag}`);
}
}
@@ -252,9 +289,21 @@ const findEnclosingFunction = (
if (provider.enclosingFunctionFinder) {
const customResult = provider.enclosingFunctionFinder(current);
if (customResult) {
// Try SymbolTable first (same pattern as the FUNCTION_NODE_TYPES branch above).
const resolved = ctx.resolve(customResult.funcName, filePath);
if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
if (resolved.candidates.length === 1) {
return resolved.candidates[0].nodeId;
}
const classInfo = findEnclosingClassInfo(current.previousSibling ?? current, filePath);
if (classInfo) {
const match = resolved.candidates.find((c) => c.ownerId === classInfo.classId);
if (match) return match.nodeId;
}
if (process.env.NODE_ENV === 'development' && classInfo) {
console.warn(
`[CallProcessor] Enclosing class '${classInfo.className}' found but no candidate matched — falling back to ${resolved.candidates[0].nodeId}`,
);
}
return resolved.candidates[0].nodeId;
}
let finalLabel = customResult.label;
@@ -262,7 +311,24 @@ const findEnclosingFunction = (
const override = provider.labelOverride(current.previousSibling!, finalLabel);
if (override !== null) finalLabel = override;
}
return generateId(finalLabel, `${filePath}:${customResult.funcName}`);
const classInfo = findEnclosingClassInfo(current.previousSibling ?? current, filePath);
const qualifiedName = classInfo
? `${classInfo.className}.${customResult.funcName}`
: customResult.funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
const sigNode = current.previousSibling ?? current;
let arity2: number | undefined;
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
const language = getLanguageFromFilename(filePath);
const info = language
? provider.methodExtractor?.extractFromNode?.(sigNode, { filePath, language })
: undefined;
if (info) {
arity2 = info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length;
}
}
const arityTag2 = arity2 !== undefined ? `#${arity2}` : '';
return generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag2}`);
}
}
@@ -563,6 +629,7 @@ export const processCalls = async (
importedReturnTypes,
importedRawReturnTypes,
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
});
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
@@ -643,6 +710,75 @@ export const processCalls = async (
if (!captureMap['call']) return;
const callNode = captureMap['call'];
const languageSeed = extractParsedCallSite(language, callNode);
if (languageSeed) {
if (provider.isBuiltInName(languageSeed.calledName)) return;
const sourceId =
findEnclosingFunction(callNode, file.path, ctx, provider) ||
generateId('File', file.path);
const receiverName =
languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined;
let receiverTypeName =
receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
if (
receiverName !== undefined &&
receiverTypeName === undefined &&
languageSeed.callForm === 'member' &&
(language === 'java' || language === 'csharp' || language === 'kotlin')
) {
const c0 = receiverName.charCodeAt(0);
if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName;
}
const resolved = resolveCallTarget(
{
calledName: languageSeed.calledName,
callForm: languageSeed.callForm,
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
...(receiverName !== undefined ? { receiverName } : {}),
},
file.path,
ctx,
undefined,
widenCache,
);
if (!resolved) return;
graph.addRelationship({
id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${resolved.nodeId}`),
sourceId,
targetId: resolved.nodeId,
type: 'CALLS',
confidence: resolved.confidence,
reason: resolved.reason,
});
if (implementorMap && languageSeed.callForm === 'member' && receiverTypeName) {
const implTargets = findInterfaceDispatchTargets(
languageSeed.calledName,
receiverTypeName,
file.path,
ctx,
implementorMap,
resolved.nodeId,
);
for (const impl of implTargets) {
graph.addRelationship({
id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${impl.nodeId}`),
sourceId,
targetId: impl.nodeId,
type: 'CALLS',
confidence: impl.confidence,
reason: impl.reason,
});
}
}
return;
}
const nameNode = captureMap['call.name'];
if (!nameNode) return;
@@ -718,7 +854,6 @@ export const processCalls = async (
if (provider.isBuiltInName(calledName)) return;
const callNode = captureMap['call'];
const callForm = inferCallForm(callNode, nameNode);
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
let receiverTypeName =
@@ -735,7 +870,8 @@ export const processCalls = async (
let p = callNode.parent;
while (p) {
if (FUNCTION_NODE_TYPES.has(p.type)) {
const { funcName } = extractFunctionName(p);
const funcName =
provider.methodExtractor?.extractFunctionName?.(p)?.funcName ?? genericFuncName(p);
if (funcName) {
scope = `${funcName}@${p.startIndex}`;
break;
@@ -893,6 +1029,41 @@ export const processCalls = async (
}
});
// Vue: emit CALLS edges for PascalCase components used in <template>.
// Template components are default-imported (not named), so we match the
// component name against imported .vue file basenames via the import map.
if (language === SupportedLanguages.Vue) {
const templateComponents = extractTemplateComponents(file.content);
if (templateComponents.length > 0) {
const fileId = generateId('File', file.path);
const importedFiles = ctx.importMap.get(file.path);
if (importedFiles) {
for (const componentName of templateComponents) {
for (const importedPath of importedFiles) {
if (!importedPath.endsWith('.vue')) continue;
const basename = importedPath.slice(
importedPath.lastIndexOf('/') + 1,
importedPath.lastIndexOf('.'),
);
if (basename !== componentName) continue;
const targetFileId = generateId('File', importedPath);
if (graph.getNode(targetFileId)) {
graph.addRelationship({
id: generateId('CALLS', `${fileId}:${componentName}->${targetFileId}`),
sourceId: fileId,
targetId: targetFileId,
type: 'CALLS',
confidence: 0.9,
reason: 'vue-template-component',
});
}
break;
}
}
}
}
}
ctx.clearCache();
}
@@ -1242,10 +1413,18 @@ const resolveCallTarget = (
/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */
const extractFuncNameFromScope = (scope: string): string => scope.slice(0, scope.indexOf('@'));
/** Extract the trailing function name from a sourceId ("Function:filepath:funcName" → "funcName"). */
/** Extract the bare function name from a sourceId.
* Handles both unqualified ("Function:filepath:funcName" → "funcName")
* and qualified ("Function:filepath:ClassName.funcName" → "funcName").
* Strips any trailing #<arity> suffix from Method/Constructor IDs. */
const extractFuncNameFromSourceId = (sourceId: string): string => {
const lastColon = sourceId.lastIndexOf(':');
return lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
const segment = lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
const dotIdx = segment.lastIndexOf('.');
const raw = dotIdx >= 0 ? segment.slice(dotIdx + 1) : segment;
// Strip #<arity> suffix (e.g. "save#2" → "save")
const hashIdx = raw.indexOf('#');
return hashIdx >= 0 ? raw.slice(0, hashIdx) : raw;
};
/**
@@ -1590,7 +1769,38 @@ export const processCallsFromExtracted = async (
widenCache,
effectiveCall.argTypes,
);
if (!resolved) continue;
if (!resolved) {
// Vue template component fallback: match calledName against imported .vue basenames
if (effectiveCall.filePath.endsWith('.vue') && effectiveCall.sourceId.startsWith('File:')) {
const importedFiles = ctx.importMap.get(effectiveCall.filePath);
if (importedFiles) {
for (const importedPath of importedFiles) {
if (!importedPath.endsWith('.vue')) continue;
const basename = importedPath.slice(
importedPath.lastIndexOf('/') + 1,
importedPath.lastIndexOf('.'),
);
if (basename !== effectiveCall.calledName) continue;
const targetFileId = generateId('File', importedPath);
if (graph.getNode(targetFileId)) {
graph.addRelationship({
id: generateId(
'CALLS',
`${effectiveCall.sourceId}:${effectiveCall.calledName}->${targetFileId}`,
),
sourceId: effectiveCall.sourceId,
targetId: targetFileId,
type: 'CALLS',
confidence: 0.9,
reason: 'vue-template-component',
});
}
break;
}
}
}
continue;
}
const relId = generateId(
'CALLS',
@@ -226,6 +226,7 @@ export const ENTRY_POINT_PATTERNS = {
/^onEvent$/, // BLoC event handler
/^mapEventToState$/, // Legacy BLoC pattern
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
} satisfies Record<SupportedLanguages, RegExp[]>;
@@ -15,12 +15,17 @@ import type { FieldVisibility } from '../../field-types.js';
/**
* Check whether any child of `node` (named or unnamed) has .text matching
* one of the given `keywords`.
* the given `keyword`.
*
* Skips the `name` field child to avoid false positives when a method is
* named after a contextual keyword (e.g. `abstract()` in TypeScript).
*/
export function hasKeyword(node: SyntaxNode, keyword: string): boolean {
const nameNode = node.childForFieldName('name');
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.text.trim() === keyword) return true;
if (!child || child === nameNode) continue;
if (child.text.trim() === keyword) return true;
}
return false;
}
@@ -46,6 +51,7 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str
/**
* Return the first matching visibility keyword found either as a direct keyword
* child or inside a modifier wrapper node.
* Skips the `name` field child (same rationale as hasKeyword).
*/
export function findVisibility(
node: SyntaxNode,
@@ -53,10 +59,12 @@ export function findVisibility(
defaultVis: FieldVisibility,
modifierNodeType?: string,
): FieldVisibility {
const nameNode = node.childForFieldName('name');
// Direct keyword children
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
const text = child?.text.trim() as FieldVisibility | undefined;
if (!child || child === nameNode) continue;
const text = child.text.trim() as FieldVisibility | undefined;
if (text && (keywords as ReadonlySet<string>).has(text)) return text;
}
// Modifier wrapper
@@ -891,6 +891,7 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
},
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
@@ -11,6 +11,7 @@ export const EXTENSIONS = [
'.ts',
'.jsx',
'.js',
'.vue',
'/index.tsx',
'/index.ts',
'/index.jsx',
@@ -0,0 +1,13 @@
/**
* Vue import resolver — delegates to TypeScript's standard resolver.
*
* Vue <script> blocks use the same import syntax as TypeScript (including
* tsconfig path aliases like `@/`), so no custom resolution logic is needed.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { resolveStandard } from './standard.js';
import type { ImportResolverFn } from './types.js';
export const resolveVueImport: ImportResolverFn = (raw, fp, ctx) =>
resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript);
+171 -1
View File
@@ -15,13 +15,27 @@ import { cCppExportChecker } from '../export-detection.js';
import { resolveCImport, resolveCppImport } from '../import-resolvers/standard.js';
import { C_QUERIES, CPP_QUERIES } from '../tree-sitter-queries.js';
import { isCppInsideClassOrStruct } from '../utils/ast-helpers.js';
/**
* Node types for standard function declarations that need C/C++ declarator handling.
* Used by cCppExtractFunctionName to determine how to extract the function name.
*/
const FUNCTION_DECLARATION_TYPES = new Set([
'function_declaration',
'function_definition',
'async_function_declaration',
'generator_function_declaration',
'function_item',
]);
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import type { LanguageProvider } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import {
cConfig as cFieldConfig,
cppConfig as cppFieldConfig,
} from '../field-extractors/configs/c-cpp.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@@ -130,6 +144,154 @@ const C_BUILT_INS: ReadonlySet<string> = new Set([
'put',
]);
/**
* C/C++ function name extraction — unwraps pointer_declarator / reference_declarator /
* function_declarator / qualified_identifier chains to find the actual function name.
* Handles field_identifier (method inside class body) and parenthesized_declarator.
*/
const cCppExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (!FUNCTION_DECLARATION_TYPES.has(node.type)) return null;
let funcName: string | null = null;
let label: NodeLabel = 'Function';
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
let declarator = node.childForFieldName?.('declarator');
if (!declarator) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') {
declarator = c;
break;
}
}
}
while (
declarator &&
(declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')
) {
let nextDeclarator = declarator.childForFieldName?.('declarator');
if (!nextDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (
c?.type === 'function_declarator' ||
c?.type === 'pointer_declarator' ||
c?.type === 'reference_declarator'
) {
nextDeclarator = c;
break;
}
}
}
declarator = nextDeclarator;
}
if (declarator) {
let innerDeclarator = declarator.childForFieldName?.('declarator');
if (!innerDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (
c?.type === 'qualified_identifier' ||
c?.type === 'identifier' ||
c?.type === 'field_identifier' ||
c?.type === 'parenthesized_declarator'
) {
innerDeclarator = c;
break;
}
}
}
if (innerDeclarator?.type === 'qualified_identifier') {
let nameNode = innerDeclarator.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (
innerDeclarator?.type === 'identifier' ||
innerDeclarator?.type === 'field_identifier'
) {
// field_identifier is used for method names inside C++ class bodies
funcName = innerDeclarator.text;
if (innerDeclarator.type === 'field_identifier') label = 'Method';
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
let nestedId: SyntaxNode | null = null;
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') {
nestedId = c;
break;
}
}
if (nestedId?.type === 'qualified_identifier') {
let nameNode = nestedId.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < nestedId.childCount; i++) {
const c = nestedId.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (nestedId?.type === 'identifier') {
funcName = nestedId.text;
}
}
}
// Fallback for other node types in FUNCTION_DECLARATION_TYPES (e.g. function_item for Rust in C++ tree)
if (!funcName) {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (
c?.type === 'identifier' ||
c?.type === 'property_identifier' ||
c?.type === 'simple_identifier'
) {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text ?? null;
}
return { funcName, label };
};
/** Check if a C/C++ function_definition is inside a class or struct body.
* Used by cppLabelOverride to skip duplicate function captures
* that are already covered by definition.method queries. */
function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}
return false;
}
/** Label override shared by C and C++: skip function_definition captures inside class/struct
* bodies (they're duplicates of definition.method captures). */
const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
@@ -149,6 +311,10 @@ export const cProvider = defineLanguage({
importResolver: resolveCImport,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(cFieldConfig),
methodExtractor: createMethodExtractor({
...cMethodConfig,
extractFunctionName: cCppExtractFunctionName,
}),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
});
@@ -163,6 +329,10 @@ export const cppProvider = defineLanguage({
importSemantics: 'wildcard',
mroStrategy: 'leftmost-base',
fieldExtractor: createFieldExtractor(cppFieldConfig),
methodExtractor: createMethodExtractor({
...cppMethodConfig,
extractFunctionName: cCppExtractFunctionName,
}),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
});
+21 -4
View File
@@ -12,7 +12,7 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import { FUNCTION_NODE_TYPES, extractFunctionName } from '../utils/ast-helpers.js';
import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
@@ -21,6 +21,8 @@ import { resolveDartImport } from '../import-resolvers/dart.js';
import { DART_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { dartMethodConfig } from '../method-extractors/configs/dart.js';
/**
* Resolve the enclosing function from a `function_body` node by looking at its
@@ -28,8 +30,8 @@ import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.
* function_body are siblings under program or class_body, unlike most languages
* where the function declaration wraps both.
*
* Delegates name extraction to the shared `extractFunctionName` which already
* handles Dart's function_signature and method_signature node types.
* Extracts the function name inline — Dart uses function_signature and
* method_signature (which wraps function_signature) as its FUNCTION_NODE_TYPES.
*/
const dartEnclosingFunctionFinder = (
node: SyntaxNode,
@@ -37,7 +39,21 @@ const dartEnclosingFunctionFinder = (
if (node.type !== 'function_body') return null;
const prev = node.previousSibling;
if (!prev || !FUNCTION_NODE_TYPES.has(prev.type)) return null;
const { funcName, label } = extractFunctionName(prev);
// method_signature wraps function_signature — unwrap to reach the name
let target = prev;
let label: NodeLabel = 'Function';
if (prev.type === 'method_signature') {
label = 'Method';
for (let i = 0; i < prev.childCount; i++) {
const c = prev.child(i);
if (c?.type === 'function_signature') {
target = c;
break;
}
}
}
const funcName = target.childForFieldName?.('name')?.text ?? null;
return funcName ? { funcName, label } : null;
};
@@ -75,6 +91,7 @@ export const dartProvider = defineLanguage({
importResolver: resolveDartImport,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(dartFieldConfig),
methodExtractor: createMethodExtractor(dartMethodConfig),
enclosingFunctionFinder: dartEnclosingFunctionFinder,
builtInNames: BUILT_INS,
});
@@ -17,6 +17,8 @@ import { resolveGoImport } from '../import-resolvers/go.js';
import { GO_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { goMethodConfig } from '../method-extractors/configs/go.js';
export const goProvider = defineLanguage({
id: SupportedLanguages.Go,
@@ -27,4 +29,5 @@ export const goProvider = defineLanguage({
importResolver: resolveGoImport,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(goFieldConfig),
methodExtractor: createMethodExtractor(goMethodConfig),
});
@@ -23,6 +23,7 @@ import { phpProvider } from './php.js';
import { rubyProvider } from './ruby.js';
import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
export const providers = {
@@ -40,6 +41,7 @@ export const providers = {
[SupportedLanguages.Ruby]: rubyProvider,
[SupportedLanguages.Swift]: swiftProvider,
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
@@ -15,12 +15,26 @@ import { resolveKotlinImport } from '../import-resolvers/jvm.js';
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
import { isKotlinClassMethod } from '../utils/ast-helpers.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { kotlinConfig } from '../field-extractors/configs/jvm.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
* Returns true when the captured definition node has a class_body ancestor. */
function isKotlinClassMethod(
captureNode: { parent?: SyntaxNode | null } | null | undefined,
): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ancestor.type === 'class_body') return true;
ancestor = ancestor.parent;
}
return false;
}
const BUILT_INS: ReadonlySet<string> = new Set([
'println',
'print',
@@ -17,6 +17,8 @@ import { findDescendant, extractStringContent, type SyntaxNode } from '../utils/
import type { NodeLabel } from 'gitnexus-shared';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { phpMethodConfig } from '../method-extractors/configs/php.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'echo',
@@ -231,6 +233,7 @@ export const phpProvider = defineLanguage({
importResolver: resolvePhpImport,
namedBindingExtractor: extractPhpNamedBindings,
fieldExtractor: createFieldExtractor(phpFieldConfig),
methodExtractor: createMethodExtractor(phpMethodConfig),
descriptionExtractor: phpDescriptionExtractor,
isRouteFile: isPhpRouteFile,
builtInNames: BUILT_INS,
@@ -19,6 +19,8 @@ import { extractPythonNamedBindings } from '../named-bindings/python.js';
import { PYTHON_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { pythonMethodConfig } from '../method-extractors/configs/python.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@@ -61,5 +63,6 @@ export const pythonProvider = defineLanguage({
importSemantics: 'namespace',
mroStrategy: 'c3',
fieldExtractor: createFieldExtractor(pythonFieldConfig),
methodExtractor: createMethodExtractor(pythonMethodConfig),
builtInNames: BUILT_INS,
});
@@ -8,7 +8,9 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
import { routeRubyCall } from '../call-routing.js';
import { rubyExportChecker } from '../export-detection.js';
@@ -16,6 +18,27 @@ import { resolveRubyImport } from '../import-resolvers/ruby.js';
import { RUBY_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { rubyMethodConfig } from '../method-extractors/configs/ruby.js';
/** Ruby method/singleton_method: extract name from 'name' field, label as Method. */
const rubyExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'method' && node.type !== 'singleton_method') return null;
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Method' };
};
const BUILT_INS: ReadonlySet<string> = new Set([
'puts',
@@ -85,5 +108,9 @@ export const rubyProvider = defineLanguage({
callRouter: routeRubyCall,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(rubyFieldConfig),
methodExtractor: createMethodExtractor({
...rubyMethodConfig,
extractFunctionName: rubyExtractFunctionName,
}),
builtInNames: BUILT_INS,
});
@@ -11,7 +11,9 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rustConfig } from '../type-extractors/rust.js';
import { rustExportChecker } from '../export-detection.js';
import { resolveRustImport } from '../import-resolvers/rust.js';
@@ -19,6 +21,37 @@ import { extractRustNamedBindings } from '../named-bindings/rust.js';
import { RUST_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { rustMethodConfig } from '../method-extractors/configs/rust.js';
/** Rust impl_item: find the function_item child and extract its name as a Method. */
const rustExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'impl_item') return null;
let funcItem: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_item') {
funcItem = c;
break;
}
}
if (!funcItem) return null;
let nameNode = funcItem.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < funcItem.childCount; i++) {
const c = funcItem.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Method' };
};
const BUILT_INS: ReadonlySet<string> = new Set([
'unwrap',
@@ -87,5 +120,9 @@ export const rustProvider = defineLanguage({
namedBindingExtractor: extractRustNamedBindings,
mroStrategy: 'qualified-syntax',
fieldExtractor: createFieldExtractor(rustFieldConfig),
methodExtractor: createMethodExtractor({
...rustMethodConfig,
extractFunctionName: rustExtractFunctionName,
}),
builtInNames: BUILT_INS,
});
@@ -11,14 +11,18 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
import { swiftExportChecker } from '../export-detection.js';
import { resolveSwiftImport } from '../import-resolvers/swift.js';
import { SWIFT_QUERIES } from '../tree-sitter-queries.js';
import type { SwiftPackageConfig } from '../language-config.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { swiftMethodConfig } from '../method-extractors/configs/swift.js';
/**
* Group Swift files by SPM target for implicit module visibility.
@@ -107,6 +111,15 @@ function wireSwiftImplicitImports(
}
}
/** Swift init/deinit declarations have special names and Constructor label. */
const swiftExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type === 'init_declaration') return { funcName: 'init', label: 'Constructor' };
if (node.type === 'deinit_declaration') return { funcName: 'deinit', label: 'Constructor' };
return null; // fall through to generic
};
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
'debugPrint',
@@ -227,6 +240,10 @@ export const swiftProvider = defineLanguage({
importSemantics: 'wildcard',
heritageDefaultEdge: 'IMPLEMENTS',
fieldExtractor: createFieldExtractor(swiftFieldConfig),
methodExtractor: createMethodExtractor({
...swiftMethodConfig,
extractFunctionName: swiftExtractFunctionName,
}),
implicitImportWirer: wireSwiftImplicitImports,
builtInNames: BUILT_INS,
});
@@ -8,7 +8,9 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
import { resolveTypescriptImport, resolveJavascriptImport } from '../import-resolvers/standard.js';
@@ -17,8 +19,38 @@ import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.j
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { javascriptConfig } from '../field-extractors/configs/typescript-javascript.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import {
typescriptMethodConfig,
javascriptMethodConfig,
} from '../method-extractors/configs/typescript-javascript.js';
const BUILT_INS: ReadonlySet<string> = new Set([
/**
* TypeScript/JavaScript: arrow_function and function_expression get their name
* from the parent variable_declarator (e.g. `const foo = () => {}`).
*/
const tsExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
const parent = node.parent;
if (parent?.type !== 'variable_declarator') return null;
let nameNode = parent.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Function' };
};
export const BUILT_INS: ReadonlySet<string> = new Set([
'console',
'log',
'warn',
@@ -124,6 +156,10 @@ export const typescriptProvider = defineLanguage({
importResolver: resolveTypescriptImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: typescriptFieldExtractor,
methodExtractor: createMethodExtractor({
...typescriptMethodConfig,
extractFunctionName: tsExtractFunctionName,
}),
builtInNames: BUILT_INS,
});
@@ -136,5 +172,9 @@ export const javascriptProvider = defineLanguage({
importResolver: resolveJavascriptImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: createFieldExtractor(javascriptConfig),
methodExtractor: createMethodExtractor({
...javascriptMethodConfig,
extractFunctionName: tsExtractFunctionName,
}),
builtInNames: BUILT_INS,
});
@@ -0,0 +1,68 @@
/**
* Vue language provider.
*
* Vue SFCs are preprocessed by extracting the <script> / <script setup>
* block content, which is then parsed as TypeScript. This provider reuses
* nearly all TypeScript infrastructure — queries, type config, field
* extraction, and named binding extraction.
*
* Export detection for <script setup> is handled directly in the parse
* worker (all top-level bindings are implicitly exported). The export
* checker here is used as fallback for non-setup <script> blocks.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
import { resolveVueImport } from '../import-resolvers/vue.js';
import { extractTsNamedBindings } from '../named-bindings/typescript.js';
import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js';
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
import { BUILT_INS as TS_BUILT_INS } from './typescript.js';
const VUE_SPECIFIC_BUILT_INS = [
'ref',
'reactive',
'computed',
'watch',
'watchEffect',
'onMounted',
'onUnmounted',
'onBeforeMount',
'onBeforeUnmount',
'onUpdated',
'onBeforeUpdate',
'nextTick',
'defineProps',
'defineEmits',
'defineExpose',
'defineOptions',
'defineSlots',
'defineModel',
'withDefaults',
'toRef',
'toRefs',
'unref',
'isRef',
'shallowRef',
'triggerRef',
'provide',
'inject',
'useSlots',
'useAttrs',
] as const;
const VUE_BUILT_INS: ReadonlySet<string> = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]);
export const vueProvider = defineLanguage({
id: SupportedLanguages.Vue,
extensions: ['.vue'],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
importResolver: resolveVueImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: typescriptFieldExtractor,
builtInNames: VUE_BUILT_INS,
});
@@ -0,0 +1,390 @@
// gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts
// Verified against tree-sitter-cpp ^0.23.4
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// C/C++ helpers
// ---------------------------------------------------------------------------
/**
* Find the function_declarator inside a method node, handling pointer/reference
* return types where the function_declarator is nested inside a pointer_declarator
* or reference_declarator.
*/
function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null {
const declarator = node.childForFieldName('declarator');
if (!declarator) return null;
if (declarator.type === 'function_declarator') return declarator;
// Recursively unwrap pointer_declarator / reference_declarator chains
// (e.g. int** (*pfn)() has pointer_declarator → pointer_declarator → function_declarator)
let current: SyntaxNode | null = declarator;
while (current) {
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child?.type === 'function_declarator') return child;
}
// Go deeper into nested pointer/reference declarators
const next = current.namedChildren.find(
(c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator',
);
current = next ?? null;
}
return null;
}
/**
* Detect `= delete` and `= default` special member function declarations.
* These are not callable methods and should be suppressed from extraction.
* tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause`
* as named children of the function_definition node.
*/
function isDeletedOrDefaulted(node: SyntaxNode): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') {
return true;
}
}
return false;
}
/**
* Extract method name from a function_declarator.
* The name is the `declarator` field of the function_declarator — typically a
* field_identifier, but can be a destructor_name (~ClassName) or operator name.
*/
function extractCppMethodName(node: SyntaxNode): string | undefined {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return undefined;
// Suppress `= delete` and `= default` special members — these are not callable
// methods and should not appear in HAS_METHOD edges.
if (isDeletedOrDefaulted(node)) return undefined;
const nameNode = funcDecl.childForFieldName('declarator');
if (!nameNode) return undefined;
// destructor_name: ~ClassName
if (nameNode.type === 'destructor_name') return nameNode.text;
// operator_name: operator==, operator+, etc.
if (nameNode.type === 'operator_name') return nameNode.text;
return nameNode.text;
}
/**
* Extract return type from the `type` field of the method node.
* tree-sitter-cpp puts the return type as the `type` field on field_declaration
* and function_definition nodes.
*/
function extractCppReturnType(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('type');
if (typeNode) {
const typeText = typeNode.text?.trim();
// C++11 trailing return type: `auto foo() -> ReturnType`
// When the declared type is `auto`, check for a trailing_return_type on the
// function_declarator which holds the actual return type.
if (typeText === 'auto') {
const funcDecl = findFunctionDeclarator(node);
if (funcDecl) {
for (let i = 0; i < funcDecl.namedChildCount; i++) {
const child = funcDecl.namedChild(i);
if (child?.type === 'trailing_return_type') {
// trailing_return_type contains a type_descriptor with the real type
const typeDesc = child.firstNamedChild;
if (typeDesc) return typeDesc.text?.trim();
}
}
}
}
return typeText;
}
// Fallback: first type-like named child (for declarations without type field)
const first = node.firstNamedChild;
if (
first &&
(first.type === 'primitive_type' ||
first.type === 'type_identifier' ||
first.type === 'sized_type_specifier' ||
first.type === 'template_type')
) {
return first.text?.trim();
}
return undefined;
}
/**
* Extract parameters from the parameter_list inside the function_declarator.
*
* C/C++ uses parameter_declaration (required) and optional_parameter_declaration
* (with default value). Variadic `...` appears as a variadic_parameter_declaration.
*/
function extractCppParameters(node: SyntaxNode): ParameterInfo[] {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return [];
const paramList = funcDecl.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'parameter_declaration': {
const typeNode = param.childForFieldName('type');
const declNode = param.childForFieldName('declarator');
// Extract name — may be wrapped in pointer_declarator or reference_declarator
const name = extractParamName(declNode);
params.push({
name: name ?? typeNode?.text?.trim() ?? '?',
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: false,
isVariadic: false,
});
break;
}
case 'optional_parameter_declaration': {
const typeNode = param.childForFieldName('type');
const declNode = param.childForFieldName('declarator');
const name = extractParamName(declNode);
params.push({
name: name ?? typeNode?.text?.trim() ?? '?',
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: true,
isVariadic: false,
});
break;
}
case 'variadic_parameter_declaration': {
// C-style `...` or typed variadic `T... args`
const typeNode = param.childForFieldName('type');
const declNode = param.childForFieldName('declarator');
const name = extractParamName(declNode);
params.push({
name: name ?? '...',
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: false,
isVariadic: true,
});
break;
}
case 'variadic_parameter': {
// Bare `...` (C-style)
params.push({
name: '...',
type: null,
isOptional: false,
isVariadic: true,
});
break;
}
}
}
// C/C++: bare `...` token in parameter list is an unnamed child (not a named node).
// Check all children for the unnamed `...` token when no variadic was detected above.
if (!params.some((p) => p.isVariadic)) {
for (let i = 0; i < paramList.childCount; i++) {
const child = paramList.child(i);
if (child && !child.isNamed && child.text === '...') {
params.push({
name: '...',
type: null,
isOptional: false,
isVariadic: true,
});
break;
}
}
}
return params;
}
/** Extract parameter name, recursively unwrapping pointer/reference declarators. */
function extractParamName(declNode: SyntaxNode | null): string | undefined {
if (!declNode) return undefined;
if (declNode.type === 'identifier') return declNode.text;
// Recursively unwrap pointer_declarator / reference_declarator chains (e.g. int** ptr)
for (let i = 0; i < declNode.namedChildCount; i++) {
const child = declNode.namedChild(i);
if (!child) continue;
if (child.type === 'identifier') return child.text;
if (child.type === 'pointer_declarator' || child.type === 'reference_declarator') {
return extractParamName(child);
}
}
return undefined;
}
/**
* Detect C++ access specifier by walking backwards through siblings.
* Mirrors the field extractor pattern in c-cpp.ts.
*/
function extractCppVisibility(node: SyntaxNode): MethodVisibility {
// If this node was unwrapped from a template_declaration, the access_specifier
// is a sibling of the template_declaration in field_declaration_list, not of
// this node — climb up one level before walking backward.
const startNode = node.parent?.type === 'template_declaration' ? node.parent : node;
let sibling = startNode.previousNamedSibling;
while (sibling) {
if (sibling.type === 'access_specifier') {
const text = sibling.text.replace(':', '').trim();
if (text === 'public' || text === 'private' || text === 'protected') return text;
}
sibling = sibling.previousNamedSibling;
}
// Default: struct/union = public, class = private
const parent = startNode.parent?.parent;
return parent?.type === 'struct_specifier' || parent?.type === 'union_specifier'
? 'public'
: 'private';
}
/**
* Detect pure virtual methods (`= 0`).
* tree-sitter-cpp emits `=` (unnamed) followed by `number_literal` with text `0`.
*/
function isPureVirtual(node: SyntaxNode): boolean {
let foundEquals = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.text === '=') {
foundEquals = true;
} else if (foundEquals && child.type === 'number_literal' && child.text === '0') {
return true;
} else if (foundEquals) {
foundEquals = false; // Reset if something else follows `=`
}
}
return false;
}
/**
* Check for a virtual_specifier ('final' or 'override') inside the function_declarator.
* In tree-sitter-cpp, these are named children of the function_declarator, not the
* method node itself.
*/
function hasVirtualSpecifier(node: SyntaxNode, keyword: string): boolean {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return false;
for (let i = 0; i < funcDecl.namedChildCount; i++) {
const child = funcDecl.namedChild(i);
if (child?.type === 'virtual_specifier' && child.text === keyword) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// C++ config
// ---------------------------------------------------------------------------
// C++ methods appear as field_declaration (declarations) or function_definition
// (inline definitions) inside field_declaration_list. The generic extractor
// iterates bodyNodeTypes children and matches against methodNodeTypes.
//
// Key difference from TS/JVM/C#: C++ has no dedicated method_declaration node.
// A field_declaration is a method if it contains a function_declarator.
// The generic extractor calls extractName() on every methodNodeType node — if
// extractName returns undefined (no function_declarator), the method is skipped.
//
// Known gaps:
// - Out-of-class method definitions (void Foo::bar() {}) are not linked as
// HAS_METHOD — they appear as top-level function_definition nodes.
// This includes namespace-wrapped and nested classes.
// - Friend declarations are not extracted.
// - Template method declarations with explicit specialization.
// - const-qualified method overloads (e.g. begin() vs begin() const) collapse
// to the same name — the schema has no isConst field to distinguish them.
export const cppMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'union_specifier'],
// declaration covers constructors/destructors; field_declaration covers method
// declarations; function_definition covers inline method definitions.
// Non-method declarations (variables, typedefs) are filtered by extractName
// returning undefined when no function_declarator is found.
methodNodeTypes: ['field_declaration', 'function_definition', 'declaration'],
bodyNodeTypes: ['field_declaration_list'],
extractName: extractCppMethodName,
extractReturnType: extractCppReturnType,
extractParameters: extractCppParameters,
extractVisibility: extractCppVisibility,
isStatic(node) {
return hasKeyword(node, 'static');
},
isAbstract(node) {
return isPureVirtual(node);
},
isFinal(node) {
return hasVirtualSpecifier(node, 'final');
},
isVirtual(node) {
// In C++, override and method-level final are only legal on virtual functions,
// so they imply virtual even without the explicit keyword.
return (
hasKeyword(node, 'virtual') ||
hasVirtualSpecifier(node, 'override') ||
hasVirtualSpecifier(node, 'final')
);
},
isOverride(node) {
return hasVirtualSpecifier(node, 'override');
},
};
// ---------------------------------------------------------------------------
// C config (minimal — C has no classes/methods, only struct function pointers)
// Verified against tree-sitter-c 0.23.2
// ---------------------------------------------------------------------------
// C does not have methods in the OOP sense. Structs with function pointer fields
// are handled by the field extractor. This config exists for completeness but
// will rarely match since C structs don't contain function_definition nodes.
export const cMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.C,
typeDeclarationNodes: ['struct_specifier'],
methodNodeTypes: ['function_definition'],
bodyNodeTypes: ['field_declaration_list'],
extractName: extractCppMethodName,
extractReturnType: extractCppReturnType,
extractParameters: extractCppParameters,
extractVisibility() {
return 'public'; // C has no access control
},
isStatic(node) {
return hasKeyword(node, 'static');
},
isAbstract() {
return false; // C has no virtual/abstract
},
isFinal() {
return false;
},
};
@@ -1,4 +1,5 @@
// gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts
// Verified against tree-sitter-c-sharp 0.23.1
import { SupportedLanguages } from 'gitnexus-shared';
import type {
@@ -186,6 +187,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
],
bodyNodeTypes: ['declaration_list'],
@@ -221,7 +223,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
// Constructors and destructors have no return type
// operator_declaration and conversion_operator_declaration use 'type' field, not 'returns'
const returnsNode = node.childForFieldName('returns');
if (returnsNode) return extractSimpleTypeName(returnsNode) ?? returnsNode.text?.trim();
if (returnsNode) return returnsNode.text?.trim();
// Fallback for operator/conversion declarations that use 'type' as return type field
if (node.type === 'operator_declaration' || node.type === 'conversion_operator_declaration') {
const typeNode = node.childForFieldName('type');
@@ -0,0 +1,405 @@
// gitnexus/src/core/ingestion/method-extractors/configs/dart.ts
// Verified against tree-sitter-dart 1.0.0 (80e23c07)
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Dart helpers
// ---------------------------------------------------------------------------
/** Type node types that represent a return type in function/getter/setter signatures. */
const TYPE_NODE_TYPES = new Set([
'type_identifier',
'generic_type',
'function_type',
'nullable_type',
'void_type',
'record_type',
]);
/**
* Dart method_signature is a WRAPPER node containing one inner signature:
* function_signature, constructor_signature, getter_signature, setter_signature,
* operator_signature, or factory_constructor_signature.
*
* Name, parameters, and return type live on the INNER signature, not on
* method_signature itself.
*/
function getInnerSignature(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child &&
(child.type === 'function_signature' ||
child.type === 'constructor_signature' ||
child.type === 'getter_signature' ||
child.type === 'setter_signature' ||
child.type === 'operator_signature' ||
child.type === 'factory_constructor_signature')
) {
return child;
}
}
// `declaration` nodes (abstract methods) also wrap function_signature as a
// named child — handled by the loop above.
return null;
}
/**
* Extract the method name from a method_signature node.
*
* Descends into the inner signature to find the name field/identifier.
*/
function extractDartName(node: SyntaxNode): string | undefined {
const inner = getInnerSignature(node);
if (!inner) return undefined;
// constructor_signature name field may include "ClassName.namedCtor" via multiple children.
// getter_signature, setter_signature, function_signature all have a 'name' field.
if (inner.type === 'operator_signature') {
// operator_signature has no 'name' field; name is 'operator' + the operator symbol
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child?.type === 'binary_operator') {
return `operator ${child.text.trim()}`;
}
}
// Check for unnamed operator tokens like []= or ~
for (let i = 0; i < inner.childCount; i++) {
const child = inner.child(i);
if (child && !child.isNamed && child.text.trim() !== 'operator') {
const text = child.text.trim();
if (text && !TYPE_NODE_TYPES.has(child.type)) {
return `operator ${text}`;
}
}
}
return undefined;
}
if (inner.type === 'getter_signature') {
const nameNode = inner.childForFieldName('name');
return nameNode?.text;
}
if (inner.type === 'setter_signature') {
const nameNode = inner.childForFieldName('name');
return nameNode ? `set ${nameNode.text}` : undefined;
}
if (inner.type === 'factory_constructor_signature') {
// Collect all identifier children to form "ClassName" or "ClassName.named"
const parts: string[] = [];
for (let i = 0; i < inner.childCount; i++) {
const child = inner.child(i);
if (child?.isNamed && child.type === 'identifier') {
parts.push(child.text);
}
}
return parts.length > 0 ? parts.join('.') : undefined;
}
// function_signature and constructor_signature both have a 'name' field
const nameNode = inner.childForFieldName('name');
if (nameNode) {
// constructor_signature: name field may be multiple identifiers joined by '.'
return nameNode.text;
}
return undefined;
}
/**
* Extract the return type from the inner signature.
*
* function_signature children include type nodes before the name.
* getter_signature children include type nodes before 'get' keyword.
* constructor/setter signatures have no return type.
*/
function extractDartReturnType(node: SyntaxNode): string | undefined {
const inner = getInnerSignature(node);
if (!inner) return undefined;
// Constructors and setters have no return type
if (
inner.type === 'constructor_signature' ||
inner.type === 'setter_signature' ||
inner.type === 'factory_constructor_signature'
) {
return undefined;
}
// For function_signature, getter_signature, operator_signature:
// The type node is a named child before the name/operator
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child && TYPE_NODE_TYPES.has(child.type)) {
return child.text?.trim();
}
}
return undefined;
}
/**
* Extract parameters from the inner signature's formal_parameter_list.
*
* Dart parameters can be:
* - Positional required: `int x`
* - Optional positional: `[int? x]` — wrapped in optional_formal_parameters with '['
* - Optional named: `{int? x}` or `{required int x}` — wrapped in optional_formal_parameters with '{'
*/
function extractDartParameters(node: SyntaxNode): ParameterInfo[] {
const inner = getInnerSignature(node);
if (!inner) return [];
// getter_signature has no parameters
if (inner.type === 'getter_signature') return [];
// Find formal_parameter_list — it's a child, not a field in function_signature
let paramList: SyntaxNode | null = null;
if (inner.type === 'constructor_signature' || inner.type === 'factory_constructor_signature') {
paramList = inner.childForFieldName('parameters');
}
if (!paramList) {
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child?.type === 'formal_parameter_list') {
paramList = child;
break;
}
}
}
if (!paramList) return [];
return extractParamsFromList(paramList, false);
}
/**
* Extract ParameterInfo entries from a formal_parameter_list or optional_formal_parameters node.
*/
function extractParamsFromList(listNode: SyntaxNode, isOptionalBlock: boolean): ParameterInfo[] {
const params: ParameterInfo[] = [];
for (let i = 0; i < listNode.namedChildCount; i++) {
const child = listNode.namedChild(i);
if (!child) continue;
if (child.type === 'formal_parameter') {
params.push(extractSingleParam(child, isOptionalBlock));
} else if (child.type === 'optional_formal_parameters') {
// Determine if these are named ({}) or positional ([]) optional params
// by checking the surrounding delimiters
params.push(...extractParamsFromList(child, true));
}
}
return params;
}
/**
* Extract a single ParameterInfo from a formal_parameter node.
*/
function extractSingleParam(param: SyntaxNode, isOptionalBlock: boolean): ParameterInfo {
const nameNode = param.childForFieldName('name');
const name = nameNode?.text ?? '<unknown>';
// Find the type node
let typeName: string | null = null;
for (let i = 0; i < param.namedChildCount; i++) {
const child = param.namedChild(i);
if (child && TYPE_NODE_TYPES.has(child.type)) {
typeName = extractSimpleTypeName(child) ?? child.text?.trim() ?? null;
break;
}
// Also check type_identifier
if (child?.type === 'type_identifier') {
typeName = child.text?.trim() ?? null;
break;
}
}
// Check for 'required' keyword:
// 1. Among children of the param node itself
let hasRequired = false;
for (let i = 0; i < param.childCount; i++) {
const child = param.child(i);
if (child && child.text.trim() === 'required') {
hasRequired = true;
break;
}
}
// 2. In tree-sitter-dart, `required` may be an anonymous sibling token
// immediately preceding the formal_parameter inside optional_formal_parameters.
if (!hasRequired) {
let prev = param.previousSibling;
// Skip comma separators
while (prev && !prev.isNamed && prev.text.trim() === ',') {
prev = prev.previousSibling;
}
if (prev && !prev.isNamed && prev.text.trim() === 'required') {
hasRequired = true;
}
}
// A parameter is optional if it's inside an optional_formal_parameters block
// and does NOT have the 'required' keyword
const isOptional = isOptionalBlock && !hasRequired;
return {
name,
type: typeName,
isOptional,
isVariadic: false, // Dart has no variadic params
};
}
/**
* Dart visibility: underscore prefix = private, else public.
*
* We resolve the name by descending into the inner signature.
*/
function extractDartVisibility(node: SyntaxNode): MethodVisibility {
const name = extractDartName(node);
if (!name) return 'public';
// Strip 'set ' or 'operator ' prefix to get the raw name
const rawName = name.startsWith('set ')
? name.slice(4)
: name.startsWith('operator ')
? name.slice(9)
: name;
return rawName.startsWith('_') ? 'private' : 'public';
}
/**
* In tree-sitter-dart, `static` is an anonymous child token of
* `method_signature` (or `declaration`), not a previous sibling.
*
* We check children first, then fall back to previous siblings for
* grammar variants.
*/
function isDartStatic(node: SyntaxNode): boolean {
// In tree-sitter-dart, `static` is an anonymous child token of method_signature
// (or declaration), not a previous sibling.
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && !child.isNamed && child.text.trim() === 'static') return true;
// Stop once we hit the inner signature — static always precedes it
if (child?.isNamed) break;
}
// Also check previous siblings (fallback for grammar variants)
let sibling = node.previousSibling;
while (sibling) {
if (sibling.isNamed && sibling.type !== 'annotation') break;
if (!sibling.isNamed && sibling.text.trim() === 'static') return true;
sibling = sibling.previousSibling;
}
return false;
}
/**
* A Dart method is abstract if it has no function_body sibling following it.
* In the tree-sitter grammar, function_body is a sibling of method_signature
* in class_body.
*/
function isDartAbstract(node: SyntaxNode, _ownerNode: SyntaxNode): boolean {
// `declaration` nodes in class_body represent abstract methods (no body, followed by ';').
// Note: extension bodies cannot have abstract members in Dart, but `declaration` nodes
// do not appear in extension_body in practice since extensions must provide implementations.
if (node.type === 'declaration') return true;
// For method_signature nodes, check if the next named sibling is a function_body
const next = node.nextNamedSibling;
return !next || next.type !== 'function_body';
}
/**
* Check for `async`, `async*`, or `sync*` keyword in the function_body sibling.
* The keyword appears as an unnamed child of function_body, or
* as a sibling keyword before function_body.
*
* Dart has three async-like forms: `async` (Future), `async*` (Stream), `sync*` (Iterable).
* All three are treated as async for graph purposes.
*/
function isDartAsync(node: SyntaxNode): boolean {
let sibling: SyntaxNode | null = node.nextSibling;
let limit = 3;
while (sibling && limit > 0) {
if (!sibling.isNamed) {
const text = sibling.text.trim();
if (text === 'async' || text === 'async*' || text === 'sync*') return true;
}
if (sibling.isNamed && sibling.type === 'function_body') {
// Check first child of function_body for async/async*/sync*
for (let i = 0; i < sibling.childCount; i++) {
const child = sibling.child(i);
if (child) {
const text = child.text.trim();
if (text === 'async' || text === 'async*' || text === 'sync*') return true;
}
// Stop at first substantial child
if (child?.isNamed) break;
}
break;
}
sibling = sibling.nextSibling;
limit--;
}
return false;
}
/**
* Extract annotations that appear as sibling nodes before the method_signature
* in class_body. Each annotation node is prefixed with '@'.
*/
function extractDartAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
let sibling = node.previousNamedSibling;
while (sibling && sibling.type === 'annotation') {
// annotation node text already includes '@', e.g. "@override"
const text = sibling.text?.trim();
if (text) {
// Normalize: strip arguments from annotation if present, keep just the name
// e.g. "@deprecated" -> "@deprecated", "@JsonKey(name: 'id')" -> "@JsonKey"
const match = text.match(/^@(\w+)/);
if (match) {
annotations.unshift('@' + match[1]);
} else {
annotations.unshift(text);
}
}
sibling = sibling.previousNamedSibling;
}
return annotations;
}
// ---------------------------------------------------------------------------
// Dart config
// ---------------------------------------------------------------------------
export const dartMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Dart,
typeDeclarationNodes: ['class_definition', 'mixin_declaration', 'extension_declaration'],
methodNodeTypes: ['method_signature', 'declaration'],
bodyNodeTypes: ['class_body', 'extension_body'],
extractName: extractDartName,
extractReturnType: extractDartReturnType,
extractParameters: extractDartParameters,
extractVisibility: extractDartVisibility,
isStatic: isDartStatic,
isAbstract: isDartAbstract,
isFinal: () => false, // Dart methods cannot be 'final'
isAsync: isDartAsync,
extractAnnotations: extractDartAnnotations,
};
@@ -0,0 +1,187 @@
// gitnexus/src/core/ingestion/method-extractors/configs/go.ts
// Verified against tree-sitter-go 0.23.4
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Go helpers
// ---------------------------------------------------------------------------
/**
* Extract the method/function name.
* - method_declaration: name is a `field_identifier`
* - function_declaration: name is an `identifier`
*/
function extractGoName(node: SyntaxNode): string | undefined {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
}
/**
* Extract return type from the `result` field.
*
* Go supports single return (`int`) and multi-return (`(User, error)`).
* Multi-return appears as a `parameter_list` — extract the first type.
*/
function extractGoReturnType(node: SyntaxNode): string | undefined {
const result = node.childForFieldName('result');
if (!result) return undefined;
// Single return type (type_identifier, pointer_type, etc.)
if (result.type !== 'parameter_list') {
return result.text?.trim();
}
// Multi-return: (Type, error) — extract first parameter's type
for (let i = 0; i < result.namedChildCount; i++) {
const param = result.namedChild(i);
if (param?.type === 'parameter_declaration') {
const typeNode = param.childForFieldName('type');
if (typeNode) return typeNode.text?.trim();
}
}
return undefined;
}
/**
* Extract parameters from the `parameters` field.
*
* Go parameter_list contains parameter_declaration nodes with optional
* `name` and required `type` fields. Go allows multiple names for one type:
* `func(a, b int)` — each name shares the type.
*
* Handles variadic_parameter_declaration (`...string`).
*/
function extractGoParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
if (param.type === 'parameter_declaration') {
const typeNode = param.childForFieldName('type');
const typeName = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
// Go allows multiple names for one type: func(a, b int)
const names: string[] = [];
for (let j = 0; j < param.namedChildCount; j++) {
const child = param.namedChild(j);
if (child?.type === 'identifier') {
names.push(child.text);
}
}
if (names.length === 0) {
// Unnamed parameter: func(int, string)
params.push({ name: `_${i}`, type: typeName, isOptional: false, isVariadic: false });
} else {
for (const name of names) {
params.push({ name, type: typeName, isOptional: false, isVariadic: false });
}
}
} else if (param.type === 'variadic_parameter_declaration') {
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
const typeName = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
params.push({
name: nameNode?.text ?? `_${i}`,
type: typeName,
isOptional: false,
isVariadic: true,
});
}
}
return params;
}
/**
* Go visibility: uppercase first character = exported (public), lowercase = unexported (private).
*/
function extractGoVisibility(node: SyntaxNode): MethodVisibility {
const name = extractGoName(node);
if (!name || name.length === 0) return 'private';
const first = name[0];
return first === first.toUpperCase() && first !== first.toLowerCase() ? 'public' : 'private';
}
/**
* Extract receiver type from the `receiver` field.
*
* The receiver is a parameter_list with one parameter_declaration:
* (r *Repo) → pointer_type → type_identifier "Repo"
* (r Repo) → type_identifier "Repo"
*/
function extractGoReceiverType(node: SyntaxNode): string | undefined {
const receiver = node.childForFieldName('receiver');
if (!receiver) return undefined;
for (let i = 0; i < receiver.namedChildCount; i++) {
const param = receiver.namedChild(i);
if (param?.type === 'parameter_declaration') {
const typeNode = param.childForFieldName('type');
if (!typeNode) continue;
// Unwrap pointer_type: *User → User
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
return inner?.text;
}
}
return undefined;
}
/**
* Resolve owner name from the receiver type.
* For function_declaration (no receiver), returns undefined.
*/
function extractGoOwnerName(node: SyntaxNode): string | undefined {
return extractGoReceiverType(node);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export const goMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Go,
// Each method_declaration/function_declaration is treated as its own "container"
// for extractFromNode() — not used with extract() in the traditional sense.
// method_elem covers interface method signatures (abstract methods).
typeDeclarationNodes: ['method_declaration', 'function_declaration', 'method_elem'],
methodNodeTypes: ['method_declaration', 'function_declaration', 'method_elem'],
bodyNodeTypes: [],
extractName: extractGoName,
extractReturnType: extractGoReturnType,
extractParameters: extractGoParameters,
extractVisibility: extractGoVisibility,
extractReceiverType: extractGoReceiverType,
extractOwnerName: extractGoOwnerName,
isStatic(node) {
// Go functions (no receiver) are effectively static
return node.type === 'function_declaration';
},
isAbstract(node, _ownerNode) {
// Go interface method signatures (method_elem) are abstract — no body
return node.type === 'method_elem';
},
isFinal(_node) {
return false; // Go has no final methods
},
};
@@ -19,7 +19,9 @@ const INTERFACE_OWNER_TYPES = new Set(['interface_declaration', 'annotation_type
function extractReturnTypeFromField(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
// Use .text to preserve full generic types (e.g. List<User>, Stream<T>)
// needed by the call resolver for return-type inference.
return typeNode.text?.trim();
}
function extractAnnotations(node: SyntaxNode, modifierType: string): string[] {
@@ -252,7 +254,7 @@ function extractKotlinReturnType(node: SyntaxNode): string | undefined {
child.type === 'nullable_type' ||
child.type === 'function_type')
) {
return extractSimpleTypeName(child) ?? child.text?.trim();
return child.text?.trim();
}
if (child.type === 'function_body') break;
}
@@ -0,0 +1,323 @@
// gitnexus/src/core/ingestion/method-extractors/configs/php.ts
// Verified against tree-sitter-php 0.23.12
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// PHP helpers
// ---------------------------------------------------------------------------
/** Regex to extract PHPDoc @return annotations: `@return User` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/** Node types to skip when walking backwards through siblings for PHPDoc. */
const PHPDOC_SKIP_NODE_TYPES: ReadonlySet<string> = new Set(['attribute_list', 'attribute']);
/**
* Normalize a PHPDoc return type for the MethodExtractor.
* Strips nullable prefix, null/false/void unions, namespace prefixes, and
* rejects uninformative types (mixed, void, self, static, object, array).
*/
function normalizePhpReturnType(raw: string): string | undefined {
let type = raw.startsWith('?') ? raw.slice(1) : raw;
const parts = type
.split('|')
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
if (parts.length !== 1) return undefined;
type = parts[0];
const segments = type.split('\\');
type = segments[segments.length - 1];
if (
type === 'mixed' ||
type === 'void' ||
type === 'self' ||
type === 'static' ||
type === 'object' ||
type === 'array'
)
return undefined;
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
return undefined;
}
/**
* Walk backwards through preceding siblings of `node` to find a PHPDoc
* `@return Type` annotation. Skips `attribute_list` nodes (PHP 8 attributes).
*/
function extractPhpDocReturnType(node: SyntaxNode): string | undefined {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = PHPDOC_RETURN_RE.exec(sibling.text);
if (match) return normalizePhpReturnType(match[1]);
} else if (sibling.isNamed && !PHPDOC_SKIP_NODE_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
}
const PHP_VIS = new Set<MethodVisibility>(['public', 'private', 'protected']);
/**
* Find the visibility keyword from a visibility_modifier named child.
* PHP tree-sitter emits `visibility_modifier` as a named node with text
* "public", "private", or "protected".
*/
function findPhpVisibility(node: SyntaxNode): MethodVisibility {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'visibility_modifier') {
const text = child.text.trim() as MethodVisibility;
if (PHP_VIS.has(text)) return text;
}
}
return 'public'; // PHP methods are public by default
}
/**
* Check for a named modifier child of a specific type.
* PHP tree-sitter uses distinct node types: abstract_modifier, final_modifier,
* static_modifier — rather than a wrapper `modifiers` node with keyword children.
*/
function hasModifierNode(node: SyntaxNode, modifierType: string): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === modifierType) return true;
}
return false;
}
/**
* Extract the return type from a PHP method_declaration node.
*
* In tree-sitter-php, the return type is not exposed via a named field.
* It appears as a type node (primitive_type, named_type, union_type,
* optional_type, nullable_type, intersection_type) after the formal_parameters
* and a `:` token separator.
*
* When the AST return type is missing or uninformative (`array` / `iterable`),
* falls back to parsing PHPDoc `@return Type` from preceding doc comments.
*/
function extractPhpReturnType(node: SyntaxNode): string | undefined {
const TYPE_NODE_TYPES = new Set([
'primitive_type',
'named_type',
'union_type',
'optional_type',
'nullable_type',
'intersection_type',
]);
let astType: string | undefined;
let seenParams = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'formal_parameters') {
seenParams = true;
continue;
}
// After the parameters node, look for the colon and then the type
if (seenParams && child.isNamed && TYPE_NODE_TYPES.has(child.type)) {
astType = child.text?.trim();
break;
}
// Stop at body or semicolon
if (child.type === 'compound_statement' || (!child.isNamed && child.text === ';')) {
break;
}
}
// If AST type is missing or uninformative, try PHPDoc @return fallback
if (!astType || astType === 'array' || astType === 'iterable') {
const docType = extractPhpDocReturnType(node);
if (docType) return docType;
}
return astType;
}
/**
* Extract parameters from a PHP method_declaration node.
*
* PHP parameter types in tree-sitter-php:
* - `simple_parameter`: regular parameter with optional type and default
* - `variadic_parameter`: `...$param` with optional type
* - `property_promotion_parameter`: constructor promotion `private string $name`
* (may also be variadic via an ERROR node containing `...`)
*/
function extractPhpParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
if (param.type === 'simple_parameter') {
const nameNode = param.childForFieldName('name');
if (!nameNode) continue;
const typeNode = param.childForFieldName('type');
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
// Detect optional: '=' token among children indicates a default value
let isOptional = false;
for (let j = 0; j < param.childCount; j++) {
const c = param.child(j);
if (c && !c.isNamed && c.text === '=') {
isOptional = true;
break;
}
}
params.push({
name: stripDollar(nameNode.text),
type: typeName ?? null,
isOptional,
isVariadic: false,
});
} else if (param.type === 'variadic_parameter') {
const nameNode = param.childForFieldName('name');
if (!nameNode) continue;
const typeNode = param.childForFieldName('type');
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
params.push({
name: stripDollar(nameNode.text),
type: typeName ?? null,
isOptional: false,
isVariadic: true,
});
} else if (param.type === 'property_promotion_parameter') {
const nameNode = param.childForFieldName('name');
if (!nameNode) continue;
const typeNode = param.childForFieldName('type');
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
// Detect variadic: an ERROR child containing "..." indicates variadic promotion
let isVariadic = false;
for (let j = 0; j < param.childCount; j++) {
const c = param.child(j);
if (c && (c.text === '...' || (c.type === 'ERROR' && c.text === '...'))) {
isVariadic = true;
break;
}
}
params.push({
name: stripDollar(nameNode.text),
type: typeName ?? null,
isOptional: false,
isVariadic,
});
}
}
return params;
}
/** Strip leading $ from PHP variable names. */
function stripDollar(name: string): string {
return name.startsWith('$') ? name.slice(1) : name;
}
/**
* Extract PHP 8 attributes (#[...]) from a method_declaration node.
*
* AST structure: attribute_list → attribute_group → attribute → name child.
* Names are prefixed with '#' to distinguish from Java/Kotlin @ annotations.
*/
function extractPhpAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child || child.type !== 'attribute_list') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const group = child.namedChild(j);
if (!group || group.type !== 'attribute_group') continue;
for (let k = 0; k < group.namedChildCount; k++) {
const attr = group.namedChild(k);
if (!attr || attr.type !== 'attribute') continue;
const nameNode = attr.firstNamedChild;
if (nameNode && nameNode.type === 'name') {
annotations.push('#' + nameNode.text);
}
}
}
}
return annotations;
}
// ---------------------------------------------------------------------------
// PHP config
// ---------------------------------------------------------------------------
export const phpMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.PHP,
typeDeclarationNodes: [
'class_declaration',
'interface_declaration',
'trait_declaration',
'enum_declaration',
],
methodNodeTypes: ['method_declaration', 'function_definition'],
bodyNodeTypes: ['declaration_list'],
extractName(node) {
return node.childForFieldName('name')?.text;
},
extractReturnType: extractPhpReturnType,
extractParameters: extractPhpParameters,
extractVisibility: findPhpVisibility,
isStatic(node) {
return hasModifierNode(node, 'static_modifier');
},
isAbstract(node, ownerNode) {
if (hasModifierNode(node, 'abstract_modifier')) return true;
// Interface methods are implicitly abstract when they have no body.
// Check ownerNode first, then fall back to walking the parent chain
// (needed when called from extractFromNode where ownerNode === node).
let isInterface = ownerNode.type === 'interface_declaration';
if (!isInterface) {
let p = node.parent;
while (p) {
if (p.type === 'interface_declaration') {
isInterface = true;
break;
}
p = p.parent;
}
}
if (isInterface) {
const body = node.childForFieldName('body');
if (body) return false;
for (let i = 0; i < node.namedChildCount; i++) {
if (node.namedChild(i)?.type === 'compound_statement') return false;
}
return true;
}
return false;
},
isFinal(node) {
return hasModifierNode(node, 'final_modifier');
},
extractAnnotations: extractPhpAnnotations,
};
@@ -0,0 +1,286 @@
// gitnexus/src/core/ingestion/method-extractors/configs/python.ts
// Verified against tree-sitter-python 0.23.4
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Python helpers
// ---------------------------------------------------------------------------
/** Names that represent the instance/class receiver — not real parameters. */
const SELF_NAMES = new Set(['self', 'cls']);
/**
* Unwrap a decorated_definition to its inner function_definition.
*
* tree-sitter-python wraps decorated functions/methods in a `decorated_definition`
* node that contains the decorators as children followed by the function_definition.
* This is different from TS/JS where decorators are siblings.
*/
function unwrapDecorated(node: SyntaxNode): SyntaxNode {
if (node.type === 'decorated_definition') {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && child.type === 'function_definition') return child;
}
}
return node;
}
/**
* Collect decorator names from a decorated_definition wrapper.
*
* Returns decorator names prefixed with '@'. If the node is a plain
* function_definition (no decorators), check if its parent is a
* decorated_definition and collect from there.
*/
function collectDecorators(node: SyntaxNode): SyntaxNode[] {
let wrapper: SyntaxNode | null = null;
if (node.type === 'decorated_definition') {
wrapper = node;
} else if (node.parent?.type === 'decorated_definition') {
wrapper = node.parent;
}
if (!wrapper) return [];
const decorators: SyntaxNode[] = [];
for (let i = 0; i < wrapper.namedChildCount; i++) {
const child = wrapper.namedChild(i);
if (child && child.type === 'decorator') {
decorators.push(child);
}
}
return decorators;
}
function extractDecoratorName(decorator: SyntaxNode): string | undefined {
// decorator > identifier (simple)
// decorator > call > identifier (call-style, e.g. @lru_cache())
// decorator > attribute (dotted, e.g. @abc.abstractmethod)
const expr = decorator.firstNamedChild;
if (!expr) return undefined;
if (expr.type === 'identifier') return '@' + expr.text;
if (expr.type === 'attribute') return '@' + expr.text;
if (expr.type === 'call') {
const fn = expr.childForFieldName('function');
return fn ? '@' + fn.text : undefined;
}
return undefined;
}
function hasDecorator(node: SyntaxNode, name: string): boolean {
const decorators = collectDecorators(node);
for (const dec of decorators) {
const decName = extractDecoratorName(dec);
if (decName === '@' + name || decName?.endsWith('.' + name)) return true;
}
return false;
}
/**
* Extract parameters from a Python function_definition.
*
* Handles: identifier, default_parameter, typed_parameter, typed_default_parameter,
* list_splat_pattern (*args), dictionary_splat_pattern (**kwargs), and typed variants.
* Skips `self` and `cls` first parameters.
*/
function extractPythonParameters(node: SyntaxNode): ParameterInfo[] {
const funcNode = unwrapDecorated(node);
const paramList = funcNode.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
let isFirst = true;
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'identifier': {
// Bare parameter: `self`, `cls`, or untyped `x`
if (isFirst && SELF_NAMES.has(param.text)) {
isFirst = false;
continue;
}
isFirst = false;
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
break;
}
case 'default_parameter': {
// `x = value` — untyped with default
isFirst = false;
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({ name: nameNode.text, type: null, isOptional: true, isVariadic: false });
}
break;
}
case 'typed_parameter': {
// `x: int` or `*args: str` or `**kwargs: int`
// The first named child can be identifier, list_splat_pattern, or dictionary_splat_pattern
const inner = param.firstNamedChild;
if (!inner) break;
if (isFirst && inner.type === 'identifier' && SELF_NAMES.has(inner.text)) {
isFirst = false;
continue;
}
isFirst = false;
const typeNode = param.childForFieldName('type');
const typeText = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
if (inner.type === 'list_splat_pattern') {
const nameId = inner.firstNamedChild;
if (nameId) {
params.push({ name: nameId.text, type: typeText, isOptional: false, isVariadic: true });
}
} else if (inner.type === 'dictionary_splat_pattern') {
const nameId = inner.firstNamedChild;
if (nameId) {
params.push({ name: nameId.text, type: typeText, isOptional: false, isVariadic: true });
}
} else {
params.push({
name: inner.text,
type: typeText,
isOptional: false,
isVariadic: false,
});
}
break;
}
case 'typed_default_parameter': {
// `x: int = 5` — typed with default
isFirst = false;
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
if (nameNode) {
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: true,
isVariadic: false,
});
}
break;
}
case 'list_splat_pattern': {
// `*args` (untyped)
isFirst = false;
const nameId = param.firstNamedChild;
if (nameId) {
params.push({ name: nameId.text, type: null, isOptional: false, isVariadic: true });
}
break;
}
case 'dictionary_splat_pattern': {
// `**kwargs` (untyped)
isFirst = false;
const nameId = param.firstNamedChild;
if (nameId) {
params.push({ name: nameId.text, type: null, isOptional: false, isVariadic: true });
}
break;
}
default:
isFirst = false;
break;
}
}
return params;
}
/**
* Extract return type from the `return_type` field.
*
* tree-sitter-python uses a `type` field on function_definition for the return
* type annotation (e.g. `-> str`). The field contains a type node.
*/
function extractPythonReturnType(node: SyntaxNode): string | undefined {
const funcNode = unwrapDecorated(node);
const returnType = funcNode.childForFieldName('return_type');
if (!returnType) return undefined;
// Use .text to preserve full generic types (e.g. list[User], Dict[str, User])
// that the call resolver needs for for-loop iterable and return-type inference.
return returnType.text?.trim();
}
/**
* Extract visibility based on Python name-mangling convention.
* `__name` (not dunder) = private, `_name` = protected, else public.
*/
function extractPythonVisibility(node: SyntaxNode): MethodVisibility {
const funcNode = unwrapDecorated(node);
const nameNode = funcNode.childForFieldName('name');
const name = nameNode?.text;
if (!name) return 'public';
if (name.startsWith('__') && !name.endsWith('__')) return 'private';
if (name.startsWith('_') && !(name.startsWith('__') && name.endsWith('__'))) return 'protected';
return 'public';
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export const pythonMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Python,
typeDeclarationNodes: ['class_definition'],
// Both function_definition and decorated_definition must be listed:
// decorated methods appear as decorated_definition in the class body block,
// while undecorated methods appear as function_definition directly.
methodNodeTypes: ['function_definition', 'decorated_definition'],
bodyNodeTypes: ['block'],
extractName(node) {
const funcNode = unwrapDecorated(node);
const nameNode = funcNode.childForFieldName('name');
return nameNode?.text;
},
extractReturnType: extractPythonReturnType,
extractParameters: extractPythonParameters,
extractVisibility: extractPythonVisibility,
isStatic(node) {
return hasDecorator(node, 'staticmethod') || hasDecorator(node, 'classmethod');
},
isAbstract(node, _ownerNode) {
return hasDecorator(node, 'abstractmethod');
},
isFinal(_node) {
return false; // @typing.final (PEP 591) is captured in annotations; isFinal not modeled
},
extractAnnotations(node) {
const decorators = collectDecorators(node);
const annotations: string[] = [];
for (const dec of decorators) {
const name = extractDecoratorName(dec);
if (name) annotations.push(name);
}
return annotations;
},
isAsync(node) {
const funcNode = unwrapDecorated(node);
return hasKeyword(funcNode, 'async');
},
};
@@ -0,0 +1,272 @@
// gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts
// Verified against tree-sitter-ruby 0.23.1
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Ruby helpers
// ---------------------------------------------------------------------------
const VISIBILITY_MODIFIERS = new Set(['private', 'protected', 'public']);
/** Regex to extract YARD `@return [Type]` annotations from comments. */
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
/**
* Extract the simple type name from a YARD type string.
* Handles qualified types ("Models::User" -> "User"), generics ("Array<User>"
* -> "Array"), nullable ("String, nil" -> "String"), and rejects ambiguous
* unions ("String, Integer" -> undefined).
*/
function extractYardTypeName(yardType: string): string | undefined {
const trimmed = yardType.trim();
// Bracket-balanced split on commas to handle generics like Hash<Symbol, User>
const parts: string[] = [];
let depth = 0,
start = 0;
for (let i = 0; i < trimmed.length; i++) {
if (trimmed[i] === '<') depth++;
else if (trimmed[i] === '>') depth--;
else if (trimmed[i] === ',' && depth === 0) {
parts.push(trimmed.slice(start, i).trim());
start = i + 1;
}
}
parts.push(trimmed.slice(start).trim());
const filtered = parts.filter((p) => p !== '' && p !== 'nil');
if (filtered.length !== 1) return undefined; // ambiguous union
const typePart = filtered[0];
// Qualified: "Models::User" -> "User"
const segments = typePart.split('::');
const last = segments[segments.length - 1];
// Generic: "Array<User>" -> "Array"
const genericMatch = last.match(/^(\w+)\s*[<{(]/);
if (genericMatch) return genericMatch[1];
// Simple identifier
if (/^\w+$/.test(last)) return last;
return undefined;
}
/**
* Extract visibility for a Ruby method by walking backwards through the
* parent body_statement's named children from the method node's position.
*
* Ruby visibility modifiers (private, protected, public) appear as bare
* `identifier` nodes in the body_statement. The most recent modifier
* before the method determines its visibility. Default is public.
*
* Example AST for:
* class Foo
* private
* def secret; end
* end
*
* body_statement
* identifier "private" ← index 0
* method "def secret" ← index 1
*/
function extractRubyVisibility(node: SyntaxNode): MethodVisibility {
const parent = node.parent;
if (!parent) return 'public';
// Find the index of this method node in the parent's named children
let methodIndex = -1;
for (let i = 0; i < parent.namedChildCount; i++) {
if (parent.namedChild(i) === node) {
methodIndex = i;
break;
}
}
if (methodIndex < 0) return 'public';
// Walk backwards from the method node looking for a visibility modifier
for (let i = methodIndex - 1; i >= 0; i--) {
const sibling = parent.namedChild(i);
if (!sibling) continue;
if (sibling.type === 'identifier' && VISIBILITY_MODIFIERS.has(sibling.text)) {
return sibling.text as MethodVisibility;
}
// module_function makes instance methods private
if (sibling.type === 'identifier' && sibling.text === 'module_function') {
return 'private';
}
}
return 'public';
}
/**
* Extract parameters from a Ruby method's method_parameters node.
*
* Handles: identifier, optional_parameter (default), splat_parameter (*args),
* hash_splat_parameter (**kwargs), block_parameter (&block), keyword_parameter.
*/
function extractRubyParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'identifier': {
// Plain parameter: def foo(x)
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
break;
}
case 'optional_parameter': {
// Default parameter: def foo(x = 10)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({ name: nameNode.text, type: null, isOptional: true, isVariadic: false });
}
break;
}
case 'splat_parameter': {
// Splat: def foo(*args)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({ name: nameNode.text, type: null, isOptional: false, isVariadic: true });
}
break;
}
case 'hash_splat_parameter': {
// Double splat: def foo(**kwargs)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({ name: nameNode.text, type: null, isOptional: false, isVariadic: true });
}
break;
}
case 'block_parameter': {
// Block: def foo(&block)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({ name: nameNode.text, type: null, isOptional: false, isVariadic: false });
}
break;
}
case 'keyword_parameter': {
// Keyword: def foo(name:) or def foo(name: "default")
const nameNode = param.childForFieldName('name');
const valueNode = param.childForFieldName('value');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
isOptional: !!valueNode,
isVariadic: false,
});
}
break;
}
}
}
return params;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export const rubyMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Ruby,
typeDeclarationNodes: ['class', 'module', 'singleton_class'],
methodNodeTypes: ['method', 'singleton_method'],
bodyNodeTypes: ['body_statement'],
extractOwnerName(node) {
// singleton_class (class << self) inherits the enclosing class/module name
if (node.type === 'singleton_class') {
let ancestor = node.parent;
while (ancestor) {
if (ancestor.type === 'class' || ancestor.type === 'module') {
const nameNode = ancestor.childForFieldName('name');
return nameNode?.text;
}
ancestor = ancestor.parent;
}
return undefined;
}
return undefined; // use default resolution for class/module
},
extractName(node) {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
},
extractReturnType(node) {
// Walk backwards through preceding siblings looking for YARD @return [Type].
// Try direct siblings first, then fall back to parent (body_statement) siblings
// for class methods where the comment may be a sibling of the body_statement.
const search = (startNode: SyntaxNode): string | undefined => {
let sibling = startNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = YARD_RETURN_RE.exec(sibling.text);
if (match) return extractYardTypeName(match[1]);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
};
const result = search(node);
if (result) return result;
if (node.parent?.type === 'body_statement') {
return search(node.parent);
}
return undefined;
},
extractParameters: extractRubyParameters,
extractVisibility: extractRubyVisibility,
isStatic(node) {
if (node.type === 'singleton_method') return true;
// module_function makes following methods callable at module level (static)
const parent = node.parent;
if (!parent) return false;
let methodIndex = -1;
for (let i = 0; i < parent.namedChildCount; i++) {
if (parent.namedChild(i) === node) {
methodIndex = i;
break;
}
}
for (let i = methodIndex - 1; i >= 0; i--) {
const sibling = parent.namedChild(i);
if (!sibling) continue;
if (sibling.type === 'identifier' && sibling.text === 'module_function') return true;
// Other visibility modifiers override module_function
if (sibling.type === 'identifier' && VISIBILITY_MODIFIERS.has(sibling.text)) return false;
}
return false;
},
isAbstract(_node, _ownerNode) {
return false; // Ruby has no abstract methods
},
isFinal(_node) {
return false; // Ruby has no final methods
},
};
@@ -0,0 +1,210 @@
// gitnexus/src/core/ingestion/method-extractors/configs/rust.ts
// Verified against tree-sitter-rust 0.23.1
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Rust helpers
// ---------------------------------------------------------------------------
/**
* Extract method name from function_item or function_signature_item.
* Both use a `name` field containing an identifier.
*/
function extractRustMethodName(node: SyntaxNode): string | undefined {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
}
/**
* Extract return type from the `return_type` field.
* tree-sitter-rust puts the return type (after `->`) as the `return_type` field.
*/
function extractRustReturnType(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('return_type');
if (!typeNode) return undefined;
return typeNode.text?.trim();
}
/**
* Extract parameters, skipping the self_parameter (handled by extractReceiverType).
*
* Rust parameters use `pattern` and `type` fields:
* parameter { pattern: identifier, type: primitive_type }
*/
function extractRustParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
// Skip self_parameter — it is the receiver, not a regular parameter
if (param.type === 'self_parameter') continue;
if (param.type === 'parameter') {
const patternNode = param.childForFieldName('pattern');
const typeNode = param.childForFieldName('type');
params.push({
name: patternNode?.text ?? '?',
type: typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null,
isOptional: false,
isVariadic: false,
});
}
}
return params;
}
/**
* Detect visibility from visibility_modifier named child.
* `pub`, `pub(crate)`, `pub(super)`, `pub(in path)` → public.
* Absence → private (Rust default).
*/
function extractRustVisibility(node: SyntaxNode): MethodVisibility {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'visibility_modifier') return 'public';
}
return 'private';
}
/**
* Detect receiver type from the first parameter if it is a self_parameter.
*
* Variants:
* - `self` → "self"
* - `&self` → "&self"
* - `&mut self` → "&mut self"
* - `mut self` → "mut self"
* - `self: Box<Self>` → "Box<Self>" (explicit self type)
*/
function extractRustReceiverType(node: SyntaxNode): string | undefined {
const paramList = node.childForFieldName('parameters');
if (!paramList) return undefined;
const first = paramList.namedChild(0);
if (!first || first.type !== 'self_parameter') return undefined;
return first.text;
}
/**
* Check whether a function_item has the `async` keyword.
* tree-sitter-rust wraps it in a `function_modifiers` named child.
*/
function isRustAsync(node: SyntaxNode): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'function_modifiers' && child.text.includes('async')) return true;
}
return false;
}
/**
* Extract attributes from preceding sibling attribute_item nodes.
*
* In tree-sitter-rust, `#[inline]` is an `attribute_item` sibling that precedes
* the function_item in the declaration_list, not a child of the function_item.
*/
function extractRustAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
let sibling = node.previousNamedSibling;
while (sibling) {
if (sibling.type === 'attribute_item') {
annotations.unshift(sibling.text);
} else {
// Stop at the first non-attribute sibling — attributes are contiguous
break;
}
sibling = sibling.previousNamedSibling;
}
return annotations;
}
// ---------------------------------------------------------------------------
// Rust config
// ---------------------------------------------------------------------------
// Rust methods live inside `impl` blocks (concrete implementations) or
// `trait` blocks (trait definitions with required/default methods).
//
// `impl_item` body contains `function_item` nodes for concrete methods.
// `trait_item` body contains `function_item` (default methods) and
// `function_signature_item` (required/abstract methods without a body).
//
// ownerName resolution: `impl_item` has no `name` field — the generic
// extractor falls back to the first `type_identifier` child, which is the
// implementing type (e.g. `impl MyStruct { ... }` → "MyStruct").
// `trait_item` uses the standard `name` field.
//
// Known gaps:
// - Macro-generated methods (e.g. derive) are not visible in the AST.
// - Unsafe methods are not distinguished (no isUnsafe field in schema).
export const rustMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Rust,
typeDeclarationNodes: ['impl_item', 'trait_item'],
methodNodeTypes: ['function_item', 'function_signature_item'],
bodyNodeTypes: ['declaration_list'],
// For `impl Trait for Struct`, resolve owner to the concrete Struct (after `for`).
// For plain `impl Struct`, resolve to Struct (first type_identifier).
// For `trait Foo`, let the default name-field resolution handle it.
extractOwnerName(node) {
if (node.type !== 'impl_item') return undefined;
const children = node.children ?? [];
const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for');
if (forIdx !== -1) {
// impl Trait for Struct — pick the type after `for`
const typeNode = children
.slice(forIdx + 1)
.find(
(c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier',
);
if (typeNode) return typeNode.text;
}
// Plain `impl Struct` — pick the first type_identifier
const first = children.find((c: SyntaxNode) => c.type === 'type_identifier');
return first?.text;
},
extractName: extractRustMethodName,
extractReturnType: extractRustReturnType,
extractParameters: extractRustParameters,
extractVisibility: extractRustVisibility,
isStatic(node) {
// A Rust method is an "associated function" (static) if it lacks a
// self_parameter as first parameter.
const paramList = node.childForFieldName('parameters');
if (!paramList) return true;
const first = paramList.namedChild(0);
return !first || first.type !== 'self_parameter';
},
isAbstract(node, ownerNode) {
// Only trait methods without a body (function_signature_item) are abstract.
// function_signature_item never has a body field.
if (ownerNode.type === 'trait_item' && node.type === 'function_signature_item') {
return true;
}
return false;
},
isFinal() {
// Rust has no `final` concept — all methods are effectively sealed
// (traits cannot be "overridden" the way Java methods can).
return false;
},
extractAnnotations: extractRustAnnotations,
extractReceiverType: extractRustReceiverType,
isAsync: isRustAsync,
};
@@ -0,0 +1,307 @@
// gitnexus/src/core/ingestion/method-extractors/configs/swift.ts
// Verified against tree-sitter-swift 0.6.0
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { findVisibility, hasKeyword, hasModifier } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Swift helpers
// ---------------------------------------------------------------------------
const SWIFT_VIS = new Set<MethodVisibility>([
'public',
'private',
'fileprivate',
'internal',
'open',
]);
/**
* Extract the method name from a function_declaration or protocol_function_declaration.
*
* In tree-sitter-swift, the name is stored in a `simple_identifier` child
* (not a 'name' field) on both function_declaration and protocol_function_declaration.
*/
function extractSwiftName(node: SyntaxNode): string | undefined {
// Try field-based name first
const nameField = node.childForFieldName('name');
if (nameField) return nameField.text;
// Walk named children for simple_identifier (the function name)
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'simple_identifier') return child.text;
}
return undefined;
}
/**
* Extract the return type from a Swift function declaration.
*
* In tree-sitter-swift, the return type appears in a `type_annotation` child
* that follows the parameter list (after `->` in source). It may also appear
* as a direct type child (user_type, optional_type, tuple_type, array_type).
*/
function extractSwiftReturnType(node: SyntaxNode): string | undefined {
// Look for the return type — typically the last type_annotation or a type node
// that appears after the parameter list.
// tree-sitter-swift places the return type inside a type child after '->'
let seenParams = false;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'parameter') {
seenParams = true;
continue;
}
// The parameter list may be unnamed children; track when we pass ')'
if (seenParams || child.type === 'type_annotation') {
if (child.type === 'type_annotation') {
const inner = child.firstNamedChild;
if (inner) return inner.text?.trim();
}
if (
child.type === 'user_type' ||
child.type === 'optional_type' ||
child.type === 'tuple_type' ||
child.type === 'array_type' ||
child.type === 'dictionary_type' ||
child.type === 'function_type'
) {
return child.text?.trim();
}
}
}
// Fallback: scan all children (named + unnamed) for '->' then grab the next named child
let seenArrow = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (!child.isNamed && child.text.trim() === '->') {
seenArrow = true;
continue;
}
if (seenArrow && child.isNamed) {
return child.text?.trim();
}
}
return undefined;
}
/**
* Extract parameters from a Swift function declaration.
*
* In tree-sitter-swift, parameters are `parameter` named children directly on
* the function_declaration node. Each parameter has:
* - An external name (label) and/or internal name as simple_identifier children
* - A type_annotation child containing the type
* - An optional default value after '='
* - A possible `...` for variadic parameters
*/
function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = [];
// In tree-sitter-swift 0.6.0, parameters are direct children of function_declaration.
// Default value tokens ('=', literal) are siblings of the parameter node at the
// function_declaration level, not children of the parameter node.
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child?.isNamed || child.type !== 'parameter') continue;
// Extract parameter name — the last simple_identifier is the internal name
let paramName: string | undefined;
for (let j = 0; j < child.namedChildCount; j++) {
const part = child.namedChild(j);
if (part?.type === 'simple_identifier') {
paramName = part.text;
}
}
if (!paramName) continue;
// Extract type — tree-sitter-swift uses user_type (not type_annotation)
let typeName: string | null = null;
for (let j = 0; j < child.namedChildCount; j++) {
const part = child.namedChild(j);
if (part?.type === 'user_type' || part?.type === 'type_annotation') {
const inner = part.firstNamedChild;
if (inner) {
typeName = extractSimpleTypeName(inner) ?? inner.text?.trim() ?? null;
} else {
typeName = part.text?.trim() ?? null;
}
break;
}
// Handle built-in types (array_type, dictionary_type, optional_type, tuple_type)
if (part?.type.endsWith('_type') && part.type !== 'simple_identifier') {
typeName = extractSimpleTypeName(part) ?? part.text?.trim() ?? null;
break;
}
}
// Check for default value: '=' token appears as a sibling after the parameter node
let isOptional = false;
const nextSibling = node.child(i + 1);
if (nextSibling && !nextSibling.isNamed && nextSibling.text.trim() === '=') {
isOptional = true;
}
// Check for variadic: '...' token among parameter children
let isVariadic = false;
for (let j = 0; j < child.childCount; j++) {
const c = child.child(j);
if (c && c.text.trim() === '...') {
isVariadic = true;
break;
}
}
params.push({
name: paramName,
type: typeName,
isOptional,
isVariadic,
});
}
return params;
}
/**
* Check if a method is inside a protocol.
*
* A protocol_function_declaration is always abstract. For function_declaration
* inside a protocol_body (if it appears there), it's also abstract when it has
* no body.
*/
function isSwiftAbstract(node: SyntaxNode, ownerNode: SyntaxNode): boolean {
// protocol_function_declaration nodes are inherently abstract
if (node.type === 'protocol_function_declaration') return true;
// function_declaration inside a protocol is abstract if it has no body
if (ownerNode.type === 'protocol_declaration') {
const body = node.childForFieldName('body');
if (!body) {
// Also check for function_body named child
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'function_body') return false;
}
return true;
}
return false;
}
return false;
}
/**
* Collect attribute nodes from a Swift function declaration.
*
* In tree-sitter-swift, attributes appear as `attribute` named children
* directly on the function_declaration node, or inside a `modifiers` wrapper.
* Each attribute node text starts with '@'.
*/
function extractSwiftAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'attribute') {
const text = child.text?.trim();
if (text) {
// Normalize: strip arguments, keep just the name
// e.g. "@objc(myMethod)" -> "@objc", "@available(iOS 13, *)" -> "@available"
const match = text.match(/^@(\w+)/);
if (match) {
annotations.push('@' + match[1]);
} else {
annotations.push(text);
}
}
}
// Also check inside modifiers wrapper
if (child.type === 'modifiers') {
for (let j = 0; j < child.namedChildCount; j++) {
const mod = child.namedChild(j);
if (mod?.type === 'attribute') {
const text = mod.text?.trim();
if (text) {
const match = text.match(/^@(\w+)/);
if (match) {
annotations.push('@' + match[1]);
} else {
annotations.push(text);
}
}
}
}
}
}
return annotations;
}
// ---------------------------------------------------------------------------
// Swift config
// ---------------------------------------------------------------------------
export const swiftMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Swift,
// tree-sitter-swift 0.6.0 may use class_declaration for classes, structs, enums, extensions,
// and actors — but this cannot be verified until the grammar installs on Node 22+.
// TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration
// node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct.
// protocol_declaration is a separate, confirmed node type.
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
// function_declaration for class/struct methods, protocol_function_declaration for protocol methods
methodNodeTypes: ['function_declaration', 'protocol_function_declaration'],
bodyNodeTypes: ['class_body', 'protocol_body'],
extractName: extractSwiftName,
extractReturnType: extractSwiftReturnType,
extractParameters: extractSwiftParameters,
extractVisibility(node) {
return findVisibility(node, SWIFT_VIS, 'internal', 'modifiers');
},
isStatic(node) {
return (
hasKeyword(node, 'static') ||
hasKeyword(node, 'class') ||
hasModifier(node, 'modifiers', 'static') ||
hasModifier(node, 'modifiers', 'class')
);
},
isAbstract: isSwiftAbstract,
isFinal(node) {
return hasKeyword(node, 'final') || hasModifier(node, 'modifiers', 'final');
},
isAsync(node) {
return hasKeyword(node, 'async') || hasModifier(node, 'modifiers', 'async');
},
isOverride(node) {
return hasKeyword(node, 'override') || hasModifier(node, 'modifiers', 'override');
},
extractAnnotations: extractSwiftAnnotations,
};
@@ -0,0 +1,324 @@
// gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts
// Verified against tree-sitter-typescript ^0.23.2, tree-sitter-javascript ^0.23.0
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// TS/JS helpers
// ---------------------------------------------------------------------------
const VISIBILITY_KEYWORDS = new Set<MethodVisibility>(['public', 'private', 'protected']);
/**
* Extract parameters from formal_parameters.
*
* Handles both TS node types (required_parameter, optional_parameter, rest_parameter)
* and JS node types (identifier, assignment_pattern, rest_pattern), plus destructured
* parameters (object_pattern, array_pattern) in both grammars.
*/
function extractTsJsParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'required_parameter': {
const patternNode = param.childForFieldName('pattern');
if (!patternNode) break;
// Skip TS `this` parameter — it's a compile-time type constraint, not a real param
if (patternNode.type === 'this') break;
// Rest parameter: pattern is a rest_pattern (...args) — extract inner identifier
const isRest = patternNode.type === 'rest_pattern';
const nameNode = isRest ? patternNode.firstNamedChild : patternNode;
if (!nameNode) break;
// type field is a type_annotation — unwrap to get the inner type node
const typeAnnotation = param.childForFieldName('type');
const typeNode = typeAnnotation?.firstNamedChild;
// Default value: presence of a 'value' field means isOptional
const hasDefault = !!param.childForFieldName('value');
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: hasDefault,
isVariadic: isRest,
});
break;
}
case 'optional_parameter': {
const nameNode = param.childForFieldName('pattern');
if (!nameNode) break;
const typeAnnotation = param.childForFieldName('type');
const typeNode = typeAnnotation?.firstNamedChild;
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: true,
isVariadic: false,
});
break;
}
case 'rest_parameter': {
const nameNode = param.childForFieldName('pattern');
if (!nameNode) break;
const typeAnnotation = param.childForFieldName('type');
const typeNode = typeAnnotation?.firstNamedChild;
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
isOptional: false,
isVariadic: true,
});
break;
}
case 'identifier': {
// JS: bare parameter name, no type info
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
break;
}
case 'assignment_pattern': {
// JS: param = defaultValue — the left side is the name, isOptional = true
const left = param.childForFieldName('left');
if (left) {
params.push({ name: left.text, type: null, isOptional: true, isVariadic: false });
}
break;
}
case 'rest_pattern': {
// JS: ...args
const inner = param.firstNamedChild;
if (inner) {
params.push({ name: inner.text, type: null, isOptional: false, isVariadic: true });
}
break;
}
case 'object_pattern':
case 'array_pattern': {
// Destructured parameter — use full text as name
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
break;
}
}
}
return params;
}
/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */
const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/;
/**
* Minimal sanitization for JSDoc return types — preserves generic wrappers
* (e.g. `Promise<User>`) so that extractReturnTypeName in call-processor
* can apply WRAPPER_GENERICS unwrapping. Only strips JSDoc-specific syntax markers.
*/
function sanitizeJsDocReturnType(raw: string): string | undefined {
let type = raw.trim();
// Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User
if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1);
// Strip module: prefix — module:models.User → models.User
if (type.startsWith('module:')) type = type.slice(7);
// Reject unions (ambiguous)
if (type.includes('|')) return undefined;
if (!type) return undefined;
return type;
}
/**
* Walk backwards through preceding siblings looking for a JSDoc comment containing
* `@returns {Type}` or `@return {Type}`. Stops at the first non-comment named node
* (excluding decorators, which precede methods in TS/JS).
*/
function extractJsDocReturnType(node: SyntaxNode): string | undefined {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = JSDOC_RETURN_RE.exec(sibling.text);
if (match) return sanitizeJsDocReturnType(match[1]);
} else if (sibling.isNamed && sibling.type !== 'decorator') break;
sibling = sibling.previousSibling;
}
return undefined;
}
/**
* Extract return type from return_type field, unwrapping type_annotation.
* Falls back to JSDoc `@returns {Type}` when the AST has no return type annotation.
*
* tree-sitter-typescript uses `return_type` as the field name (not `type` like JVM).
* The return_type field points to a type_annotation node that must be unwrapped.
*/
function extractTsJsReturnType(node: SyntaxNode): string | undefined {
const returnType = node.childForFieldName('return_type');
if (returnType) {
if (returnType.type === 'type_annotation') {
const inner = returnType.firstNamedChild;
if (inner) return inner.text?.trim();
}
return returnType.text?.trim();
}
// AST has no return type annotation — try JSDoc fallback
return extractJsDocReturnType(node);
}
/**
* Extract visibility from accessibility_modifier or #private name.
*
* tree-sitter-typescript emits accessibility_modifier as a named child of method nodes
* (not as a modifiers wrapper like JVM). Pass 1 scans for that child; pass 2 checks for
* ES2022 private_property_identifier (#name). Default: public.
*/
function extractTsJsVisibility(node: SyntaxNode): MethodVisibility {
// Pass 1: check for accessibility_modifier named child (TS-specific)
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && child.type === 'accessibility_modifier') {
const t = child.text.trim();
if (VISIBILITY_KEYWORDS.has(t as MethodVisibility)) return t as MethodVisibility;
}
}
// Pass 2: ES2022 private methods (#name) are inherently private
const nameNode = node.childForFieldName('name');
if (nameNode && nameNode.type === 'private_property_identifier') return 'private';
// No accessibility_modifier found — default to public.
// Note: tree-sitter-typescript does not wrap modifiers in a 'modifiers' node
// (unlike JVM), so there is no wrapper to scan.
return 'public';
}
/**
* Extract decorator names, prefixed with '@'.
*
* In tree-sitter-typescript, decorators are **siblings** of the method_definition in the
* class_body — they are NOT children of the method node. We find them by walking backwards
* from the method node through its preceding siblings in the parent body.
*/
function extractTsJsDecorators(node: SyntaxNode): string[] {
const decorators: string[] = [];
// Walk backwards via previousNamedSibling to collect consecutive decorator siblings.
// This avoids the O(N) index-finding scan through the parent's children.
let sibling = node.previousNamedSibling;
while (sibling && sibling.type === 'decorator') {
const name = extractDecoratorName(sibling);
if (name) decorators.unshift(name);
sibling = sibling.previousNamedSibling;
}
return decorators;
}
function extractDecoratorName(decorator: SyntaxNode): string | undefined {
const expr = decorator.firstNamedChild;
if (!expr) return undefined;
if (expr.type === 'call_expression') {
const fn = expr.childForFieldName('function');
return fn ? '@' + fn.text : undefined;
}
if (expr.type === 'identifier') return '@' + expr.text;
if (expr.type === 'member_expression') return '@' + expr.text;
return undefined;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
// TS and JS share the same config base. TS-only node types (abstract_class_declaration,
// interface_declaration, abstract_method_signature, method_signature, interface_body) are
// included because the JS grammar never produces these nodes — they are harmless no-ops.
// This mirrors the field extractor's typescript-javascript.ts shared pattern.
//
// Note: TS and JS share a method config but NOT a field extractor because the TS field
// extractor needs a hand-written class for type_alias_declaration object literals and
// nested type discovery. Methods have no such requirement.
const shared: Omit<MethodExtractionConfig, 'language'> = {
typeDeclarationNodes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
],
// Note: TS constructors are method_definition nodes (name = 'constructor'), so no
// explicit constructor_declaration entry is needed (unlike JVM/C# configs).
// Known gaps:
// - call_signature and construct_signature (e.g., interface Fn { (x: string): void; })
// are not extracted — they have no name field and are uncommon in practice.
// - class_expression (const Foo = class { ... }) — methods inside class expressions
// are not discovered because class_expression is not in typeDeclarationNodes.
// - declare module / declare global augmentations — methods inside ambient_module_declaration
// wrappers are not surfaced because the top-level walker doesn't descend into them.
methodNodeTypes: [
'method_definition',
'method_signature',
'abstract_method_signature',
'function_declaration',
'generator_function_declaration',
'function_signature',
],
bodyNodeTypes: ['class_body', 'interface_body'],
extractName(node) {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
},
extractReturnType: extractTsJsReturnType,
extractParameters: extractTsJsParameters,
extractVisibility: extractTsJsVisibility,
isStatic(node) {
return hasKeyword(node, 'static');
},
isAbstract(node, ownerNode) {
// Explicit abstract keyword on the method itself
if (hasKeyword(node, 'abstract')) return true;
// Interface methods are implicitly abstract — TS interfaces never have method bodies
// (unlike Java default methods), so no !body check needed
if (ownerNode.type === 'interface_declaration') return true;
return false;
},
isFinal(_node) {
return false; // TS/JS has no final/sealed methods
},
extractAnnotations: extractTsJsDecorators,
isAsync(node) {
return hasKeyword(node, 'async');
},
isOverride(node) {
return hasKeyword(node, 'override');
},
};
export const typescriptMethodConfig: MethodExtractionConfig = {
...shared,
language: SupportedLanguages.TypeScript,
};
export const javascriptMethodConfig: MethodExtractionConfig = {
...shared,
language: SupportedLanguages.JavaScript,
};
@@ -16,8 +16,8 @@ import type {
MethodInfo,
} from '../method-types.js';
/** Owner node types where member functions are effectively static (JVM semantics). */
const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration']);
/** Owner node types where member functions are effectively static (JVM/Ruby semantics). */
const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration', 'singleton_class']);
/**
* Create a MethodExtractor from a declarative config.
@@ -37,17 +37,27 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
if (!typeDeclarationSet.has(node.type)) return null;
// Resolve owner name: field-based → type_identifier → simple_identifier → "Companion"
// Resolve owner name: config hook → field-based → type_identifier → simple_identifier → "Companion"
let ownerName: string | undefined;
const nameField = node.childForFieldName('name');
if (nameField) {
ownerName = nameField.text;
} else {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && (child.type === 'type_identifier' || child.type === 'simple_identifier')) {
ownerName = child.text;
break;
if (config.extractOwnerName) {
ownerName = config.extractOwnerName(node);
}
if (!ownerName) {
const nameField = node.childForFieldName('name');
if (nameField) {
ownerName = nameField.text;
} else {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child &&
(child.type === 'type_identifier' ||
child.type === 'simple_identifier' ||
child.type === 'identifier')
) {
ownerName = child.text;
break;
}
}
}
}
@@ -71,6 +81,13 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
return { ownerName, methods };
},
extractFromNode(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null {
if (!methodNodeSet.has(node.type)) return null;
return buildMethod(node, node, context, config);
},
...(config.extractFunctionName ? { extractFunctionName: config.extractFunctionName } : {}),
};
}
@@ -102,10 +119,17 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): SyntaxNode[] {
return result;
}
function addNestedBodies(parent: SyntaxNode, bodyNodeSet: Set<string>, out: SyntaxNode[]): void {
function addNestedBodies(
parent: SyntaxNode,
bodyNodeSet: Set<string>,
out: SyntaxNode[],
seen?: Set<SyntaxNode>,
): void {
const visited = seen ?? new Set(out);
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && bodyNodeSet.has(child.type) && !out.includes(child)) {
if (child && bodyNodeSet.has(child.type) && !visited.has(child)) {
visited.add(child);
out.push(child);
}
}
@@ -120,9 +144,15 @@ function extractMethodsFromBody(
out: MethodInfo[],
): void {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
let child = body.namedChild(i);
if (!child) continue;
// C++ template methods are wrapped in template_declaration — unwrap to the inner node
if (child.type === 'template_declaration') {
const inner = child.namedChildren.find((c) => methodNodeSet.has(c.type));
if (inner) child = inner;
}
if (methodNodeSet.has(child.type)) {
const method = buildMethod(child, ownerNode, context, config);
if (method) out.push(method);
@@ -46,6 +46,16 @@ export interface MethodExtractor {
language: SupportedLanguages;
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null;
isTypeDeclaration(node: SyntaxNode): boolean;
/** Extract method info from a standalone method node (e.g. Go top-level method_declaration). */
extractFromNode?(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null;
/** Extract function name + label from an AST node during parent-walk.
* Languages with non-standard AST structures (e.g. C/C++ declarator
* unwrapping, Swift init/deinit, Rust impl_item) provide this hook
* to replace the generic name-field lookup.
* Return null to fall through to the generic extractor. */
extractFunctionName?(
node: SyntaxNode,
): { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
}
export interface MethodExtractionConfig {
@@ -66,9 +76,16 @@ export interface MethodExtractionConfig {
isOverride?: (node: SyntaxNode) => boolean;
isAsync?: (node: SyntaxNode) => boolean;
isPartial?: (node: SyntaxNode) => boolean;
/** Resolve the owner name from a standalone method node (e.g. Go receiver type). */
extractOwnerName?: (node: SyntaxNode) => string | undefined;
/** Extract a primary constructor from the owner node itself (e.g. C# 12 class Point(int x, int y)). */
extractPrimaryConstructor?: (
ownerNode: SyntaxNode,
context: MethodExtractorContext,
) => MethodInfo | null;
/** Extract function name + label from an AST node during parent-walk.
* Passed through to the MethodExtractor by createMethodExtractor. */
extractFunctionName?: (
node: SyntaxNode,
) => { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
}
+402 -13
View File
@@ -3,7 +3,7 @@
*
* Walks the inheritance DAG (EXTENDS/IMPLEMENTS edges), collects methods from
* each ancestor via HAS_METHOD edges, detects method-name collisions across
* parents, and applies language-specific resolution rules to emit OVERRIDES edges.
* parents, and applies language-specific resolution rules to emit METHOD_OVERRIDES edges.
*
* Language-specific rules:
* - C++: leftmost base class in declaration order wins
@@ -13,10 +13,10 @@
* - Rust: no auto-resolution — requires qualified syntax, resolvedTo = null
* - Default: single inheritance — first definition wins
*
* OVERRIDES edge direction: Class → Method (not Method → Method).
* METHOD_OVERRIDES edge direction: Class → Method (not Method → Method).
* The source is the child class that inherits conflicting methods,
* the target is the winning ancestor method node.
* Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'OVERRIDES'}]->(m:Method)
* Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'METHOD_OVERRIDES'}]->(m:Method)
*/
import { KnowledgeGraph } from '../graph/types.js';
@@ -47,6 +47,7 @@ export interface MROResult {
entries: MROEntry[];
overrideEdges: number;
ambiguityCount: number;
methodImplementsEdges: number;
}
// ---------------------------------------------------------------------------
@@ -289,6 +290,10 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
let overrideEdges = 0;
let ambiguityCount = 0;
// Pre-computed maps to avoid redundant BFS in emitMethodImplementsEdges
const ancestorsMap = new Map<string, string[]>();
const edgeTypesMap = new Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>();
// Process every class that has at least one parent
for (const [classId, directParents] of parentMap) {
if (directParents.length === 0) continue;
@@ -302,12 +307,16 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
// Compute linearized MRO depending on language strategy
const provider = getProvider(language);
const ancestors = gatherAncestors(classId, parentMap);
ancestorsMap.set(classId, ancestors);
edgeTypesMap.set(classId, buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType));
let mroOrder: string[];
if (provider.mroStrategy === 'c3') {
const c3Result = c3Linearize(classId, parentMap, c3Cache);
mroOrder = c3Result ?? gatherAncestors(classId, parentMap);
mroOrder = c3Result ?? ancestors;
} else {
mroOrder = gatherAncestors(classId, parentMap);
mroOrder = ancestors;
}
// Get the parent names for the MRO entry
@@ -348,11 +357,9 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
// Detect collisions: methods defined in 2+ different ancestors
const ambiguities: MethodAmbiguity[] = [];
// Compute transitive edge types once per class (only needed for implements-split languages)
// Use pre-computed transitive edge types (only needed for implements-split languages)
const needsEdgeTypes = provider.mroStrategy === 'implements-split';
const classEdgeTypes = needsEdgeTypes
? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType)
: undefined;
const classEdgeTypes = needsEdgeTypes ? edgeTypesMap.get(classId) : undefined;
for (const [methodName, defs] of methodsByName) {
if (defs.length < 2) continue;
@@ -401,13 +408,13 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
ambiguityCount++;
}
// Emit OVERRIDES edge if resolution found
// Emit METHOD_OVERRIDES edge if resolution found
if (resolution.resolvedTo !== null) {
graph.addRelationship({
id: generateId('OVERRIDES', `${classId}->${resolution.resolvedTo}`),
id: generateId('METHOD_OVERRIDES', `${classId}->${resolution.resolvedTo}`),
sourceId: classId,
targetId: resolution.resolvedTo,
type: 'OVERRIDES',
type: 'METHOD_OVERRIDES',
confidence: resolution.confidence,
reason: resolution.reason,
});
@@ -424,7 +431,389 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
});
}
return { entries, overrideEdges, ambiguityCount };
const methodImplementsEdges = emitMethodImplementsEdges(
graph,
parentMap,
methodMap,
parentEdgeType,
ancestorsMap,
edgeTypesMap,
);
return { entries, overrideEdges, ambiguityCount, methodImplementsEdges };
}
// ---------------------------------------------------------------------------
// METHOD_IMPLEMENTS edge emission
// ---------------------------------------------------------------------------
/**
* Check if two parameter type arrays match.
* When either side has no type info, fall back to parameterCount comparison
* (arity-compatible matching). If both have parameterCount and they differ,
* return no match. If counts match, return confident match. If either count
* is undefined, return lenient (non-confident) match.
*
* Returns `{ match, confident }`:
* - Exact type match → `{ match: true, confident: true }`
* - Arity match (both have parameterCount, counts equal) → `{ match: true, confident: true }`
* - Lenient (either side lacks types AND lacks parameterCount) → `{ match: true, confident: false }`
* - No match → `{ match: false, confident: false }`
*/
function parameterTypesMatch(
a: string[],
b: string[],
aParamCount?: number,
bParamCount?: number,
): { match: boolean; confident: boolean } {
// If one side is variadic and the other isn't, types may match superficially
// but the methods aren't guaranteed to be interchangeable
if ((aParamCount === undefined) !== (bParamCount === undefined)) {
return { match: true, confident: false };
}
if (a.length === 0 || b.length === 0) {
// Fall back to arity check when type info is missing
if (aParamCount !== undefined && bParamCount !== undefined) {
return { match: aParamCount === bParamCount, confident: aParamCount === bParamCount };
}
return { match: true, confident: false }; // lenient when either count is unknown
}
if (a.length !== b.length) return { match: false, confident: false };
const exact = a.every((t, i) => t === b[i]);
return { match: exact, confident: exact };
}
/**
* For each concrete class that implements/extends an interface or trait,
* find methods in the class that implement methods defined in the interface
* and emit METHOD_IMPLEMENTS edges: ConcreteMethod → InterfaceMethod.
*
* Method node IDs include a `#<paramCount>` arity suffix, so overloaded
* methods with different parameter counts are distinct nodes in the graph.
*
* **Remaining limitation — same-arity overloads:** When two overloads share
* the same parameter count but differ only in types (e.g. `save(int)` vs
* `save(String)`), they still collapse to one node ID. This is rare in
* practice; a future enhancement may add type-hash disambiguation for
* languages with reliable type extraction (see issue #574).
*/
function emitMethodImplementsEdges(
graph: KnowledgeGraph,
parentMap: Map<string, string[]>,
methodMap: Map<string, string[]>,
parentEdgeType: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
ancestorsMap: Map<string, string[]>,
edgeTypesMap: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
): number {
let edgeCount = 0;
for (const [classId, parentIds] of parentMap) {
const classNode = graph.getNode(classId);
if (!classNode) continue;
// Interfaces and traits declare contracts — they don't implement them
if (classNode.label === 'Interface' || classNode.label === 'Trait') continue;
// Get this class's own methods
const ownMethodIds = methodMap.get(classId) ?? [];
// Build a lookup: methodName → Array<{methodId, parameterTypes, parameterCount}> for own methods
const ownMethodsByName = new Map<
string,
Array<{ methodId: string; parameterTypes: string[]; parameterCount?: number }>
>();
for (const methodId of ownMethodIds) {
const methodNode = graph.getNode(methodId);
if (!methodNode || methodNode.label === 'Property') continue;
// Abstract methods don't satisfy interface contracts
if (methodNode.properties.isAbstract === true) continue;
const name = methodNode.properties.name as string;
const parameterTypes = (methodNode.properties.parameterTypes as string[] | undefined) ?? [];
const parameterCount = methodNode.properties.parameterCount as number | undefined;
let bucket = ownMethodsByName.get(name);
if (!bucket) {
bucket = [];
ownMethodsByName.set(name, bucket);
}
bucket.push({ methodId, parameterTypes, parameterCount });
}
// Use pre-computed ancestors and edge types; fall back to computing if missing (safety)
const allAncestors = ancestorsMap.get(classId) ?? gatherAncestors(classId, parentMap);
const ancestorEdgeTypes =
edgeTypesMap.get(classId) ?? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType);
// Dedup set: avoid duplicate edges from diamond paths
const emitted = new Set<string>();
// For each ancestor, check if it's an interface/trait or classified as IMPLEMENTS
for (const ancestorId of allAncestors) {
const ancestorNode = graph.getNode(ancestorId);
if (!ancestorNode) continue;
const isInterfaceLike = ancestorNode.label === 'Interface' || ancestorNode.label === 'Trait';
const classifiedEdgeType = ancestorEdgeTypes.get(ancestorId);
if (!isInterfaceLike && classifiedEdgeType !== 'IMPLEMENTS') continue;
// Get ancestor's methods
const ancestorMethodIds = methodMap.get(ancestorId) ?? [];
for (const ancestorMethodId of ancestorMethodIds) {
const ancestorMethodNode = graph.getNode(ancestorMethodId);
if (!ancestorMethodNode || ancestorMethodNode.label === 'Property') continue;
const ancestorName = ancestorMethodNode.properties.name as string;
const ancestorParamTypes =
(ancestorMethodNode.properties.parameterTypes as string[] | undefined) ?? [];
const ancestorParamCount = ancestorMethodNode.properties.parameterCount as
| number
| undefined;
// Find matching method in own class by name + parameterTypes/arity
const candidates = ownMethodsByName.get(ancestorName);
// Unit 3: If no own method matches, walk the EXTENDS chain to find inherited concrete method
if (!candidates || candidates.length === 0) {
const inherited = findInheritedMethod(
classId,
ancestorName,
ancestorParamTypes,
ancestorParamCount,
graph,
parentMap,
methodMap,
parentEdgeType,
ancestorMethodId,
);
if (inherited) {
const edgeKey = `${inherited.methodId}->${ancestorMethodId}`;
if (!emitted.has(edgeKey)) {
emitted.add(edgeKey);
graph.addRelationship({
id: generateId('METHOD_IMPLEMENTS', edgeKey),
sourceId: inherited.methodId,
targetId: ancestorMethodId,
type: 'METHOD_IMPLEMENTS',
confidence: inherited.confident ? 1.0 : 0.7,
reason: '',
});
edgeCount++;
}
}
continue;
}
// Unit 4: Filter candidates by type/arity match, then check for ambiguity
const matching: Array<{
methodId: string;
parameterTypes: string[];
parameterCount?: number;
confident: boolean;
}> = [];
for (const c of candidates) {
const result = parameterTypesMatch(
c.parameterTypes,
ancestorParamTypes,
c.parameterCount,
ancestorParamCount,
);
if (result.match) {
matching.push({ ...c, confident: result.confident });
}
}
if (matching.length === 0) continue;
// If multiple candidates match at name+arity level, emit no edge (ambiguous)
if (matching.length > 1) continue;
const winner = matching[0];
const edgeKey = `${winner.methodId}->${ancestorMethodId}`;
if (emitted.has(edgeKey)) continue;
emitted.add(edgeKey);
graph.addRelationship({
id: generateId('METHOD_IMPLEMENTS', edgeKey),
sourceId: winner.methodId,
targetId: ancestorMethodId,
type: 'METHOD_IMPLEMENTS',
confidence: winner.confident ? 1.0 : 0.7,
reason: '',
});
edgeCount++;
}
}
}
return edgeCount;
}
/**
* Walk the class's EXTENDS chain to find the nearest concrete method matching
* the given name and parameter signature. If the EXTENDS chain yields no match,
* fall back to IMPLEMENTS parents and check for non-abstract default methods
* (e.g. Java default interface methods, Kotlin interface defaults).
* Returns the first matching method found in BFS order, or null.
*/
function findInheritedMethod(
classId: string,
methodName: string,
targetParamTypes: string[],
targetParamCount: number | undefined,
graph: KnowledgeGraph,
parentMap: Map<string, string[]>,
methodMap: Map<string, string[]>,
parentEdgeType: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
/** Method ID to exclude from results (prevents self-edges when the ancestor
* method being matched lives on an IMPLEMENTS parent). */
excludeMethodId?: string,
): { methodId: string; parameterTypes: string[]; confident: boolean } | null {
const visited = new Set<string>();
const queue: string[] = [];
// Seed with direct EXTENDS parents only
const directParents = parentMap.get(classId) ?? [];
const directEdges = parentEdgeType.get(classId);
for (const pid of directParents) {
const et = directEdges?.get(pid);
if (et === 'EXTENDS') {
// Also check that the parent is not an Interface/Trait
const parentNode = graph.getNode(pid);
if (parentNode && parentNode.label !== 'Interface' && parentNode.label !== 'Trait') {
queue.push(pid);
}
}
}
// Level-order BFS: process all ancestors at the current depth before
// advancing. Once any match is found at depth D, finish that depth and stop.
// Diamond dedup: same methodId via two paths at the same depth = 1 match.
let currentLevel = [...queue];
while (currentLevel.length > 0) {
const matches = new Map<
string,
{ methodId: string; parameterTypes: string[]; confident: boolean }
>();
const nextLevel: string[] = [];
for (const ancestorId of currentLevel) {
if (visited.has(ancestorId)) continue;
visited.add(ancestorId);
// Check this ancestor's methods
const methods = methodMap.get(ancestorId) ?? [];
for (const mid of methods) {
const mNode = graph.getNode(mid);
if (!mNode || mNode.label === 'Property') continue;
// Abstract inherited methods don't count as concrete implementations
if (mNode.properties.isAbstract === true) continue;
if (mNode.properties.name !== methodName) continue;
const mParamTypes = (mNode.properties.parameterTypes as string[] | undefined) ?? [];
const mParamCount = mNode.properties.parameterCount as number | undefined;
const ptResult = parameterTypesMatch(
mParamTypes,
targetParamTypes,
mParamCount,
targetParamCount,
);
if (ptResult.match) {
matches.set(mid, {
methodId: mid,
parameterTypes: mParamTypes,
confident: ptResult.confident,
});
}
}
// Collect EXTENDS parents for the next depth level
const grandparents = parentMap.get(ancestorId) ?? [];
const ancestorEdges = parentEdgeType.get(ancestorId);
for (const gp of grandparents) {
if (visited.has(gp)) continue;
const gpEdge = ancestorEdges?.get(gp);
if (gpEdge === 'EXTENDS') {
const gpNode = graph.getNode(gp);
if (gpNode && gpNode.label !== 'Interface' && gpNode.label !== 'Trait') {
nextLevel.push(gp);
}
}
}
}
// If any matches found at this depth, decide and stop
if (matches.size === 1) return matches.values().next().value!;
if (matches.size > 1) return null; // ambiguous at same depth
currentLevel = nextLevel;
}
// ── Second pass: walk IMPLEMENTS parents AND their interface ancestry ──
// Only reached when the EXTENDS chain yielded no match.
// BFS through interface/trait hierarchy to find default (non-abstract) methods.
const implBfsQueue: string[] = [];
for (const pid of directParents) {
const et = directEdges?.get(pid);
if (et === 'IMPLEMENTS') {
implBfsQueue.push(pid);
}
}
// Collect all matches from the IMPLEMENTS BFS — return null if ambiguous (>1 match)
const implMatches: Array<{
methodId: string;
parameterTypes: string[];
confident: boolean;
}> = [];
const implVisited = new Set<string>();
while (implBfsQueue.length > 0) {
const ifaceId = implBfsQueue.shift()!;
if (implVisited.has(ifaceId)) continue;
implVisited.add(ifaceId);
// Only process Interface/Trait nodes — Dart `implements Class` does not
// inherit method bodies, so Class/Struct/Enum parents must be skipped.
const ifaceNode = graph.getNode(ifaceId);
if (!ifaceNode || (ifaceNode.label !== 'Interface' && ifaceNode.label !== 'Trait')) continue;
// Check this interface/trait's methods for a non-abstract default
const methods = methodMap.get(ifaceId) ?? [];
for (const mid of methods) {
if (mid === excludeMethodId) continue; // prevent self-edges
const mNode = graph.getNode(mid);
if (!mNode || mNode.label === 'Property') continue;
if (mNode.properties.isAbstract === true) continue;
if (mNode.properties.name !== methodName) continue;
const mParamTypes = (mNode.properties.parameterTypes as string[] | undefined) ?? [];
const mParamCount = mNode.properties.parameterCount as number | undefined;
const ptResult = parameterTypesMatch(
mParamTypes,
targetParamTypes,
mParamCount,
targetParamCount,
);
if (ptResult.match) {
implMatches.push({
methodId: mid,
parameterTypes: mParamTypes,
confident: ptResult.confident,
});
}
}
// Walk this interface's parents (interface-extends-interface chains)
const ifaceParents = parentMap.get(ifaceId) ?? [];
for (const gp of ifaceParents) {
if (!implVisited.has(gp)) implBfsQueue.push(gp);
}
}
// Ambiguous: multiple interfaces provide the same default method
if (implMatches.length === 1) return implMatches[0];
return null; // 0 matches or ambiguous (>1)
}
/**
+143 -71
View File
@@ -6,19 +6,22 @@ import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import {
getDefinitionNodeFromCaptures,
findEnclosingClassId,
extractMethodSignature,
findEnclosingClassInfo,
getLabelFromCaptures,
CLASS_CONTAINER_TYPES,
type SyntaxNode,
type EnclosingClassInfo,
} from './utils/ast-helpers.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { buildTypeEnv } from './type-env.js';
import type { FieldInfo, FieldExtractorContext } from './field-types.js';
import type { MethodInfo } from './method-types.js';
import { buildMethodProps, arityForIdFromInfo } from './utils/method-props.js';
import type { LanguageProvider } from './language-provider.js';
import { WorkerPool } from './workers/worker-pool.js';
import type {
@@ -184,14 +187,17 @@ const processParsingWithWorkers = async (
// Inline caches to avoid repeated parent-walks per node (same pattern as parse-worker.ts).
// Keyed by tree-sitter node reference — cleared at the start of each file.
const classIdCache = new Map<SyntaxNode, string | null>();
const classInfoCache = new Map<SyntaxNode, EnclosingClassInfo | null>();
const exportCache = new Map<SyntaxNode, boolean>();
const cachedFindEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
const cached = classIdCache.get(node);
const cachedFindEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
): EnclosingClassInfo | null => {
const cached = classInfoCache.get(node);
if (cached !== undefined) return cached;
const result = findEnclosingClassId(node, filePath);
classIdCache.set(node, result);
const result = findEnclosingClassInfo(node, filePath);
classInfoCache.set(node, result);
return result;
};
@@ -210,6 +216,13 @@ const cachedExportCheck = (
// FieldExtractor cache for sequential path — same pattern as parse-worker.ts
const seqFieldInfoCache = new Map<number, Map<string, FieldInfo>>();
// MethodExtractor cache for sequential path — avoids re-traversing the same class
// body once per method. Keyed on classNode.id (tree-sitter node identity number).
const seqMethodExtractCache = new Map<
number,
{ ownerName: string | undefined; methods: MethodInfo[] } | null
>();
function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
let current = node.parent;
while (current) {
@@ -259,9 +272,10 @@ const processParsingSequential = async (
const file = files[i];
// Reset memoization before each new file (node refs are per-tree)
classIdCache.clear();
classInfoCache.clear();
exportCache.clear();
seqFieldInfoCache.clear();
seqMethodExtractCache.clear();
onFileProgress?.(i + 1, total, file.path);
@@ -280,6 +294,18 @@ const processParsingSequential = async (
// Skip files larger than the max tree-sitter buffer (32 MB)
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
// Vue SFC preprocessing: extract <script> block content
let parseContent = file.content;
let lineOffset = 0;
let isVueSetup = false;
if (language === SupportedLanguages.Vue) {
const extracted = extractVueScript(file.content);
if (!extracted) continue; // skip .vue files with no script block
parseContent = extracted.scriptContent;
lineOffset = extracted.lineOffset;
isVueSetup = extracted.isSetup;
}
try {
await loadLanguage(language, file.path);
} catch {
@@ -288,8 +314,8 @@ const processParsingSequential = async (
let tree;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content.length),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent.length),
});
} catch (parseError) {
console.warn(`Skipping unparseable file: ${file.path}`);
@@ -317,7 +343,10 @@ const processParsingSequential = async (
// Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor)
const typeEnv = provider.fieldExtractor
? buildTypeEnv(tree, language, { enclosingFunctionFinder: provider.enclosingFunctionFinder })
? buildTypeEnv(tree, language, {
enclosingFunctionFinder: provider.enclosingFunctionFinder,
extractFunctionName: provider.methodExtractor?.extractFunctionName,
})
: null;
matches.forEach((match) => {
@@ -337,84 +366,127 @@ const processParsingSequential = async (
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNodeForRange
? definitionNodeForRange.startPosition.row
? definitionNodeForRange.startPosition.row + lineOffset
: nameNode
? nameNode.startPosition.row
: 0;
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
? nameNode.startPosition.row + lineOffset
: lineOffset;
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
const needsOwner =
nodeLabel === 'Method' ||
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
const enclosingClassInfo = needsOwner
? cachedFindEnclosingClassInfo(nameNode || definitionNodeForRange, file.path)
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
// Qualify method/property IDs with enclosing class name to avoid collisions
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
const qualifiedName = enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// Extract method metadata for Function/Method/Constructor nodes BEFORE generating
// the node ID — parameterCount is needed to disambiguate overloaded methods.
// Use the per-language MethodExtractor for method metadata (isAbstract, isStatic,
// visibility, annotations, parameterCount, parameterTypes, returnType, etc.).
const isMethodLike =
nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor';
let methodProps: Record<string, unknown> = {};
let arityForId: number | undefined; // raw param count for ID, even for variadic
if (isMethodLike && definitionNode) {
let enriched = false;
if (provider.methodExtractor) {
// Try class-based extraction (method inside a class/struct/trait body)
const classNode = seqFindEnclosingClassNode(definitionNode);
if (classNode) {
// Cache extract() results per class node to avoid re-traversing the
// same class body for every method it contains (O(N) -> O(1) per hit).
let result:
| { ownerName: string | undefined; methods: MethodInfo[] }
| null
| undefined = seqMethodExtractCache.get(classNode.id);
if (result === undefined) {
result =
provider.methodExtractor.extract(classNode, {
filePath: file.path,
language,
}) ?? null;
seqMethodExtractCache.set(classNode.id, result);
}
if (result?.methods?.length) {
const defLine = definitionNode.startPosition.row + 1;
const info = result.methods.find((m) => m.name === nodeName && m.line === defLine);
if (info) {
enriched = true;
arityForId = arityForIdFromInfo(info);
methodProps = buildMethodProps(info);
}
}
}
// For top-level methods (e.g. Go method_declaration), try extractFromNode
if (!enriched && provider.methodExtractor.extractFromNode) {
const info = provider.methodExtractor.extractFromNode(definitionNode, {
filePath: file.path,
language,
});
if (info) {
enriched = true;
arityForId = arityForIdFromInfo(info);
methodProps = buildMethodProps(info);
}
}
}
}
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
// Functions are not suffixed — they don't overload by name in the same scope.
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
const arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
// Extract method signature for Method/Constructor nodes
const methodSig =
nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor'
? extractMethodSignature(definitionNode)
: undefined;
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
if (
methodSig &&
(!methodSig.returnType ||
methodSig.returnType === 'array' ||
methodSig.returnType === 'iterable') &&
definitionNode
) {
const tc = provider.typeConfig;
if (tc?.extractReturnType) {
const docReturn = tc.extractReturnType(definitionNode);
if (docReturn) methodSig.returnType = docReturn;
}
}
const node: GraphNode = {
id: nodeId,
label: nodeLabel as NodeLabel,
properties: {
name: nodeName,
filePath: file.path,
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
startLine: definitionNodeForRange
? definitionNodeForRange.startPosition.row + lineOffset
: startLine,
endLine: definitionNodeForRange
? definitionNodeForRange.endPosition.row + lineOffset
: startLine,
language: language,
isExported: cachedExportCheck(
provider.exportChecker,
nameNode || definitionNodeForRange,
nodeName,
),
isExported:
language === SupportedLanguages.Vue && isVueSetup
? isVueSetupTopLevel(nameNode || definitionNodeForRange)
: cachedExportCheck(
provider.exportChecker,
nameNode || definitionNodeForRange,
nodeName,
),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
}
: {}),
...(methodSig
? {
parameterCount: methodSig.parameterCount,
...(methodSig.requiredParameterCount !== undefined
? { requiredParameterCount: methodSig.requiredParameterCount }
: {}),
...(methodSig.parameterTypes ? { parameterTypes: methodSig.parameterTypes } : {}),
returnType: methodSig.returnType,
}
: {}),
...methodProps,
},
};
graph.addNode(node);
// Compute enclosing class for Method/Constructor/Property/Function — used for both ownerId and HAS_METHOD
// Function is included because Kotlin/Rust/Python capture class methods as Function nodes
const needsOwner =
nodeLabel === 'Method' ||
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
const enclosingClassId = needsOwner
? cachedFindEnclosingClassId(nameNode || definitionNodeForRange, file.path)
: null;
// enclosingClassId already computed above (before nodeId generation)
// Extract declared type and field metadata for Property nodes
let declaredType: string | undefined;
@@ -441,7 +513,7 @@ const processParsingSequential = async (
}
}
}
// All 14 languages register a FieldExtractor — no fallback needed.
// All 15 tree-sitter languages register a FieldExtractor — no fallback needed.
}
// Apply field metadata to the graph node retroactively
@@ -451,10 +523,10 @@ const processParsingSequential = async (
if (declaredType !== undefined) node.properties.declaredType = declaredType;
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
parameterCount: methodSig?.parameterCount,
requiredParameterCount: methodSig?.requiredParameterCount,
parameterTypes: methodSig?.parameterTypes,
returnType: methodSig?.returnType,
parameterCount: methodProps.parameterCount as number | undefined,
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
parameterTypes: methodProps.parameterTypes as string[] | undefined,
returnType: methodProps.returnType as string | undefined,
declaredType,
ownerId: enclosingClassId ?? undefined,
});
+2 -2
View File
@@ -1103,7 +1103,7 @@ async function runChunkedParseAndResolve(
* Post-parse graph analysis: MRO, community detection, process extraction.
*
* @reads graph (all nodes and relationships from parse + resolve phases)
* @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, OVERRIDES edges)
* @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, METHOD_OVERRIDES edges)
*/
async function runGraphAnalysisPhases(
graph: ReturnType<typeof createKnowledgeGraph>,
@@ -1126,7 +1126,7 @@ async function runGraphAnalysisPhases(
const mroResult = computeMRO(graph);
if (isDev && mroResult.entries.length > 0) {
console.log(
`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`,
`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`,
);
}
@@ -11,6 +11,9 @@ export const TYPESCRIPT_QUERIES = `
(class_declaration
name: (type_identifier) @name) @definition.class
(abstract_class_declaration
name: (type_identifier) @name) @definition.class
(interface_declaration
name: (type_identifier) @name) @definition.interface
@@ -24,6 +27,18 @@ export const TYPESCRIPT_QUERIES = `
(method_definition
name: (property_identifier) @name) @definition.method
; ES2022 #private methods (private_property_identifier not matched by property_identifier)
(method_definition
name: (private_property_identifier) @name) @definition.method
; Abstract method signatures in abstract classes
(abstract_method_signature
name: (property_identifier) @name) @definition.method
; Interface method signatures
(method_signature
name: (property_identifier) @name) @definition.method
(lexical_declaration
(variable_declarator
name: (identifier) @name
@@ -145,6 +160,10 @@ export const JAVASCRIPT_QUERIES = `
(method_definition
name: (property_identifier) @name) @definition.method
; ES2022 #private methods
(method_definition
name: (private_property_identifier) @name) @definition.method
(lexical_declaration
(variable_declarator
name: (identifier) @name
@@ -330,6 +349,7 @@ export const JAVA_QUERIES = `
; Calls
(method_invocation name: (identifier) @call.name) @call
(method_invocation object: (_) name: (identifier) @call.name) @call
(method_reference) @call
; Constructor calls: new Foo()
(object_creation_expression type: (type_identifier) @call.name) @call
@@ -615,6 +635,7 @@ export const CSHARP_QUERIES = `
export const RUST_QUERIES = `
; Functions & Items
(function_item name: (identifier) @name) @definition.function
(function_signature_item name: (identifier) @name) @definition.function
(struct_item name: (type_identifier) @name) @definition.struct
(enum_item name: (type_identifier) @name) @definition.enum
(trait_item name: (type_identifier) @name) @definition.trait
@@ -1105,6 +1126,13 @@ export const DART_QUERIES = `
value: (identifier) @call.name
(selector (argument_part))) @call
; ── Calls: member calls in variable assignments (var x = obj.method()) ──────
(initialized_variable_definition
(selector
(unconditional_assignable_selector
(identifier) @call.name))
(selector (argument_part))) @call
; ── Re-exports (export 'foo.dart') ───────────────────────────────────────────
(import_or_export
(library_export
@@ -1163,5 +1191,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Ruby]: RUBY_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
};
+45 -46
View File
@@ -1,8 +1,8 @@
import {
type SyntaxNode,
FUNCTION_NODE_TYPES,
extractFunctionName,
CLASS_CONTAINER_TYPES,
genericFuncName,
} from './utils/ast-helpers.js';
import { CALL_EXPRESSION_TYPES } from './utils/call-analysis.js';
import { SupportedLanguages } from 'gitnexus-shared';
@@ -132,6 +132,7 @@ const lookupInEnv = (
callNode: SyntaxNode,
patternOverrides?: PatternOverrides,
enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null,
extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null,
): string | undefined => {
// Self/this receiver: resolve to enclosing class name via AST walk
if (varName === 'self' || varName === 'this' || varName === '$this') {
@@ -145,7 +146,11 @@ const lookupInEnv = (
}
// Determine the enclosing function scope for the call
const scopeKey = findEnclosingScopeKey(callNode, enclosingFunctionFinder);
const scopeKey = findEnclosingScopeKey(
callNode,
enclosingFunctionFinder,
extractFunctionNameHook,
);
// Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts).
// These take priority over flat scopeEnv because they represent per-branch narrowing.
@@ -361,11 +366,12 @@ const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined =
const findEnclosingScopeKey = (
node: SyntaxNode,
enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null,
extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null,
): string | undefined => {
let current = node.parent;
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const { funcName } = extractFunctionName(current);
const funcName = extractFunctionNameHook?.(current)?.funcName ?? genericFuncName(current);
if (funcName) return `${funcName}@${current.startIndex}`;
}
// Language-specific hook (e.g., Dart function_body → sibling function_signature)
@@ -621,7 +627,15 @@ const resolveMethodReturnType = (
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
const receiverType = scopeEnv.get(receiver);
let receiverType = scopeEnv.get(receiver);
// When substituteThisReceiver replaced $this/self with the enclosing class name,
// the receiver IS the type — look it up directly as a class name.
if (!receiverType) {
const lookup =
getClassDefs ??
((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type)));
if (lookup(receiver).length > 0) receiverType = receiver;
}
if (!receiverType) return undefined;
const lookup =
getClassDefs ??
@@ -765,6 +779,11 @@ export interface BuildTypeEnvOptions {
enclosingFunctionFinder?: (
ancestorNode: SyntaxNode,
) => { funcName: string; label: NodeLabel } | null;
/** Language-specific function name extraction from an AST node.
* Replaces the generic name-field lookup for languages with non-standard
* AST structures (C/C++ declarator unwrapping, Swift init/deinit, etc.).
* When null is returned or not provided, falls back to node.childForFieldName('name')?.text. */
extractFunctionName?: (node: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null;
}
/** Seed cross-file type bindings into the file scope.
@@ -794,6 +813,7 @@ export const buildTypeEnv = (
const symbolTable = options?.symbolTable;
const parentMap = options?.parentMap;
const extractFuncNameHook = options?.extractFunctionName;
const env: TypeEnv = new Map();
const patternOverrides: PatternOverrides = new Map();
// Phase P: maps `scope\0varName` → constructor type when a declaration has BOTH
@@ -955,47 +975,19 @@ export const buildTypeEnv = (
// This decouples type node capture from scopeEnv success — container types
// (User[], []User, List[User]) that fail extractSimpleTypeName still get
// their AST type node recorded for Strategy 1 for-loop resolution.
// Try direct extraction first (works for Go var_spec, Python assignment, Rust let_declaration).
// Try direct type field first, then unwrap wrapper nodes (C# field_declaration,
// local_declaration_statement wrap their type inside a variable_declaration child).
let typeNode = node.childForFieldName('type');
//
// Prefer language-specific locator when provided (keeps buildTypeEnv generic),
// then fall back to a small set of safe, cross-grammar heuristics.
let typeNode =
config.getDeclarationTypeNode?.(node) ?? node.childForFieldName('type') ?? null;
// Fallback: some grammars wrap type annotations in a `type_annotation` child
// instead of exposing a named `type` field on the declaration node.
if (!typeNode) {
// C# field_declaration / local_declaration_statement wrap type inside variable_declaration.
// Use manual loop instead of namedChildren.find() to avoid array allocation on hot path.
let wrapped = node.childForFieldName('declaration');
if (!wrapped) {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'variable_declaration') {
wrapped = c;
break;
}
}
}
if (wrapped) {
typeNode = wrapped.childForFieldName('type');
// Kotlin: variable_declaration stores the type as user_type / nullable_type
// child rather than a named 'type' field.
if (!typeNode) {
for (let i = 0; i < wrapped.namedChildCount; i++) {
const c = wrapped.namedChild(i);
if (c && (c.type === 'user_type' || c.type === 'nullable_type')) {
typeNode = c;
break;
}
}
}
}
// Swift: property_declaration has type_annotation as a direct child (not a 'type' field).
// Extract the inner type node (array_type, user_type, etc.) for declarationTypeNodes.
if (!typeNode) {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'type_annotation') {
// Use the inner type (array_type, user_type) rather than the annotation wrapper
typeNode = c.firstNamedChild ?? c;
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'type_annotation') {
typeNode = c.firstNamedChild ?? c;
break;
}
}
}
@@ -1085,7 +1077,7 @@ export const buildTypeEnv = (
// Detect scope boundaries (function/method definitions)
let scope = currentScope;
if (FUNCTION_NODE_TYPES.has(node.type)) {
const { funcName } = extractFunctionName(node);
const funcName = extractFuncNameHook?.(node)?.funcName ?? genericFuncName(node);
if (funcName) scope = `${funcName}@${node.startIndex}`;
}
@@ -1234,7 +1226,14 @@ export const buildTypeEnv = (
return {
lookup: (varName, callNode) =>
lookupInEnv(env, varName, callNode, patternOverrides, options?.enclosingFunctionFinder),
lookupInEnv(
env,
varName,
callNode,
patternOverrides,
options?.enclosingFunctionFinder,
extractFuncNameHook,
),
constructorBindings: bindings,
fileScope: () => env.get(FILE_SCOPE) ?? EMPTY_FILE_SCOPE,
allScopes: () => env as ReadonlyMap<string, ReadonlyMap<string, string>>,
@@ -559,6 +559,24 @@ const inferLiteralType: LiteralTypeInferrer = (node) => {
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
getDeclarationTypeNode: (node) => {
// C# field_declaration / local_declaration_statement wrap type inside variable_declaration.
// Prefer the wrapper node's `type` field when present.
const direct = node.childForFieldName('type');
if (direct) return direct;
const wrapped =
node.childForFieldName('declaration') ??
(() => {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'variable_declaration') return c;
}
return null;
})();
return wrapped?.childForFieldName('type') ?? null;
},
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set([
'is_pattern_expression',
@@ -850,6 +850,20 @@ const extractKotlinPatternBinding: PatternBindingExtractor = (
export const kotlinTypeConfig: LanguageTypeConfig = {
allowPatternBindingOverwrite: true,
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,
getDeclarationTypeNode: (node) => {
// Kotlin property_declaration wraps the actual declaration in variable_declaration.
// The type is commonly a user_type / nullable_type child (positional, not 'type' field).
const varDecl =
node.type === 'property_declaration' ? findChild(node, 'variable_declaration') : node;
if (varDecl) {
return (
varDecl.childForFieldName('type') ??
findChild(varDecl, 'user_type') ??
findChild(varDecl, 'nullable_type')
);
}
return node.childForFieldName('type') ?? findChild(node, 'user_type') ?? null;
},
forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['type_test', 'equality_expression']),
extractDeclaration: extractKotlinDeclaration,
@@ -6,7 +6,6 @@ import type {
InitializerExtractor,
ClassNameLookup,
ConstructorBindingScanner,
ReturnTypeExtractor,
PendingAssignmentExtractor,
ForLoopExtractor,
} from './types.js';
@@ -337,60 +336,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return undefined;
};
/** Regex to extract PHPDoc @return annotations: `@return User` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/**
* Normalize a PHPDoc return type for storage in the SymbolTable.
* Unlike normalizePhpType (which strips User[] → User for scopeEnv), this preserves
* array notation so lookupRawReturnType can extract element types for for-loop resolution.
* \App\Models\User[] → User[]
* ?User → User
* Collection<User> → Collection<User> (preserved for extractElementTypeFromString)
*/
const normalizePhpReturnType = (raw: string): string | undefined => {
// Strip nullable prefix: ?User[] → User[]
let type = raw.startsWith('?') ? raw.slice(1) : raw;
// Strip union with null/false/void: User[]|null → User[]
const parts = type
.split('|')
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
if (parts.length !== 1) return undefined;
type = parts[0];
// Strip namespace: \App\Models\User[] → User[]
const segments = type.split('\\');
type = segments[segments.length - 1];
// Skip uninformative types
if (
type === 'mixed' ||
type === 'void' ||
type === 'self' ||
type === 'static' ||
type === 'object' ||
type === 'array'
)
return undefined;
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
return undefined;
};
/**
* Extract return type from PHPDoc `@return Type` annotation preceding a method.
* Walks backwards through preceding siblings looking for comment nodes.
* Preserves array notation (e.g., User[]) for for-loop element type extraction.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = PHPDOC_RETURN_RE.exec(sibling.text);
if (match) return normalizePhpReturnType(match[1]);
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break;
sibling = sibling.previousSibling;
}
return undefined;
};
/** PHP: $alias = $user → assignment_expression with variable_name left/right.
* PHP TypeEnv stores variables WITH $ prefix ($user → User), so we keep $ in lhs/rhs. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
@@ -605,7 +550,6 @@ export const typeConfig: LanguageTypeConfig = {
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -4,7 +4,6 @@ import type {
TypeBindingExtractor,
InitializerExtractor,
ConstructorBindingScanner,
ReturnTypeExtractor,
PendingAssignmentExtractor,
ForLoopExtractor,
} from './types.js';
@@ -43,9 +42,6 @@ const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g;
/** Alternate YARD order: `@param [Type] name` */
const YARD_PARAM_ALT_RE = /@param\s+\[([^\]]+)\]\s+(\w+)/g;
/** Regex to extract @return annotations: `@return [Type]` */
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
/**
* Extract the simple type name from a YARD type string.
* Handles:
@@ -229,35 +225,6 @@ const extractInitializer: InitializerExtractor = (node, env, classNames): void =
}
};
/**
* Extract return type from YARD `@return [Type]` annotation preceding a method.
* Reuses the same comment-walking strategy as collectYardParams: try direct
* siblings first, fall back to parent (body_statement) siblings for class methods.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
const search = (startNode: SyntaxNode): string | undefined => {
let sibling = startNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = YARD_RETURN_RE.exec(sibling.text);
if (match) return extractYardTypeName(match[1]);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
};
const result = search(node);
if (result) return result;
if (node.parent?.type === 'body_statement') {
return search(node.parent);
}
return undefined;
};
/**
* Ruby constructor binding scanner: captures both `user = User.new` and
* plain call assignments like `user = get_user()`.
@@ -452,7 +419,6 @@ export const typeConfig: LanguageTypeConfig = {
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -496,6 +496,17 @@ function extractSwiftElementTypeFromTypeNode(typeNode: SyntaxNode): string | und
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
getDeclarationTypeNode: (node) => {
// Swift: many declarations store type as a type_annotation child (not a 'type' field).
// Prefer a direct 'type' field if present, else unwrap type_annotation to its inner type.
const direct = node.childForFieldName('type');
if (direct) return direct;
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'type_annotation') return c.firstNamedChild ?? c;
}
return null;
},
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
@@ -6,6 +6,11 @@ export type TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>)
/** Extracts type bindings from a parameter node into the env map */
export type ParameterExtractor = (node: SyntaxNode, env: Map<string, string>) => void;
/** Optionally locates the type-annotation AST node for a declaration node.
* Used by buildTypeEnv to populate declarationTypeNodes and constructorTypeMap.
* If absent, buildTypeEnv falls back to generic heuristics (childForFieldName('type'), etc). */
export type DeclarationTypeNodeLocator = (node: SyntaxNode) => SyntaxNode | null;
/** Minimal interface for checking whether a name is a known class/struct.
* Narrower than ReadonlySet — only `.has()` is used by extractors. */
export type ClassNameLookup = { has(name: string): boolean };
@@ -25,11 +30,6 @@ export type ConstructorBindingScanner = (
node: SyntaxNode,
) => { varName: string; calleeName: string; receiverClassName?: string } | undefined;
/** Extracts a return type string from a method/function definition node.
* Used for languages where return types are expressed in comments (e.g. YARD @return [Type])
* rather than in AST fields. Returns undefined if no return type can be determined. */
export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined;
/** Infer the type name of a literal AST node for overload disambiguation.
* Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined
* for non-literal nodes. Only used when resolveCallTarget has multiple candidates
@@ -142,6 +142,9 @@ export interface LanguageTypeConfig {
readonly allowPatternBindingOverwrite?: boolean;
/** Node types that represent typed declarations for this language */
declarationNodeTypes: ReadonlySet<string>;
/** Optional: language-specific way to find a declaration's type-annotation node.
* Prefer providing this for grammars where the type is wrapped (e.g., C#, Kotlin, Swift). */
getDeclarationTypeNode?: DeclarationTypeNodeLocator;
/** AST node types for for-each/for-in statements with explicit element types. */
forLoopNodeTypes?: ReadonlySet<string>;
/** Optional allowlist of AST node types on which extractPatternBinding should run.
@@ -162,9 +165,6 @@ export interface LanguageTypeConfig {
* Called on every AST node during buildTypeEnv walk; returns undefined for non-matches.
* The callee binding is unverified — the caller must confirm against the SymbolTable. */
scanConstructorBinding?: ConstructorBindingScanner;
/** Extract return type from comment-based annotations (e.g. YARD @return [Type]).
* Called as fallback when extractMethodSignature finds no AST-based return type. */
extractReturnType?: ReturnTypeExtractor;
/** Extract loop variable → type binding from a for-each AST node. */
extractForLoopBinding?: ForLoopExtractor;
/** Extract pending assignment for Tier 2 propagation.
@@ -6,7 +6,6 @@ import type {
InitializerExtractor,
ClassNameLookup,
ConstructorBindingScanner,
ReturnTypeExtractor,
PendingAssignmentExtractor,
PendingAssignment,
ForLoopExtractor,
@@ -198,44 +197,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return { varName: nameNode.text, calleeName };
};
/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */
const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/;
/**
* Minimal sanitization for JSDoc return types — preserves generic wrappers
* (e.g. `Promise<User>`) so that extractReturnTypeName in call-processor
* can apply WRAPPER_GENERICS unwrapping. Unlike normalizeJsDocType (which
* strips generics), this only strips JSDoc-specific syntax markers.
*/
const sanitizeReturnType = (raw: string): string | undefined => {
let type = raw.trim();
// Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User
if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1);
// Strip module: prefix — module:models.User → models.User
if (type.startsWith('module:')) type = type.slice(7);
// Reject unions (ambiguous)
if (type.includes('|')) return undefined;
if (!type) return undefined;
return type;
};
/**
* Extract return type from JSDoc `@returns {Type}` or `@return {Type}` annotation
* preceding a function/method definition. Walks backwards through preceding siblings
* looking for comment nodes containing the annotation.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = JSDOC_RETURN_RE.exec(sibling.text);
if (match) return sanitizeReturnType(match[1]);
} else if (sibling.isNamed && sibling.type !== 'decorator') break;
sibling = sibling.previousSibling;
}
return undefined;
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_in_statement']);
/** TS function/method node types that carry a parameters list. */
@@ -480,8 +441,40 @@ const extractForLoopBinding: ForLoopExtractor = (
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};
/** Collect fieldAccess items from an object_pattern's destructured properties. */
const collectDestructuredFields = (
nameNode: SyntaxNode,
receiver: string,
scopeEnv: ReadonlyMap<string, string>,
): PendingAssignment[] => {
const items: PendingAssignment[] = [];
for (let j = 0; j < nameNode.namedChildCount; j++) {
const prop = nameNode.namedChild(j);
if (!prop) continue;
if (prop.type === 'shorthand_property_identifier_pattern') {
// `const { name } = obj` → shorthand: varName = fieldName
const varName = prop.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
}
} else if (prop.type === 'pair_pattern') {
// `const { address: addr } = obj` → pair_pattern: key=field, value=varName
const keyNode = prop.childForFieldName('key');
const valNode = prop.childForFieldName('value');
if (keyNode && valNode) {
const fieldName = keyNode.text;
const varName = valNode.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
}
}
}
}
return items;
};
/** TS/JS: const alias = u → variable_declarator with name/value fields.
* Also handles destructuring: `const { a, b } = obj` → N fieldAccess items. */
* Also handles destructuring: `const { a, b } = obj` and `const { a } = fn()` → N fieldAccess items. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
@@ -490,34 +483,50 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const valueNode = child.childForFieldName('value');
if (!nameNode || !valueNode) continue;
// Object destructuring: `const { address, name } = user`
// Emits N fieldAccess items — one per destructured binding.
// Object destructuring from identifier: `const { address, name } = user`
if (nameNode.type === 'object_pattern' && valueNode.type === 'identifier') {
const receiver = valueNode.text;
const items: PendingAssignment[] = [];
for (let j = 0; j < nameNode.namedChildCount; j++) {
const prop = nameNode.namedChild(j);
if (!prop) continue;
if (prop.type === 'shorthand_property_identifier_pattern') {
// `const { name } = user` → shorthand: varName = fieldName
const varName = prop.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
}
} else if (prop.type === 'pair_pattern') {
// `const { address: addr } = user` → pair_pattern: key=field, value=varName
const keyNode = prop.childForFieldName('key');
const valNode = prop.childForFieldName('value');
if (keyNode && valNode) {
const fieldName = keyNode.text;
const varName = valNode.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
const items = collectDestructuredFields(nameNode, valueNode.text, scopeEnv);
if (items.length > 0) return items;
continue;
}
// Object destructuring from call/await: `const { x } = fn()` or `const { x } = await fn()`
// Emits a synthetic callResult + N fieldAccess items resolved via fixpoint iteration.
if (nameNode.type === 'object_pattern') {
const callNode = unwrapAwait(valueNode);
if (callNode?.type === 'call_expression') {
const funcNode = callNode.childForFieldName('function');
if (funcNode) {
let syntheticVar: string | undefined;
let leadItem: PendingAssignment | undefined;
if (funcNode.type === 'identifier') {
syntheticVar = `__destr_${funcNode.text}_${callNode.startIndex}`;
leadItem = { kind: 'callResult', lhs: syntheticVar, callee: funcNode.text };
} else if (funcNode.type === 'member_expression') {
const obj = funcNode.childForFieldName('object');
const prop = funcNode.childForFieldName('property');
if (
obj &&
prop?.type === 'property_identifier' &&
(obj.type === 'identifier' || obj.type === 'this')
) {
syntheticVar = `__destr_${prop.text}_${callNode.startIndex}`;
leadItem = {
kind: 'methodCallResult',
lhs: syntheticVar,
receiver: obj.text,
method: prop.text,
};
}
}
if (syntheticVar && leadItem) {
const fieldItems = collectDestructuredFields(nameNode, syntheticVar, scopeEnv);
if (fieldItems.length > 0) return [leadItem, ...fieldItems];
}
}
}
if (items.length > 0) return items;
continue;
}
@@ -694,7 +703,6 @@ export const typeConfig: LanguageTypeConfig = {
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
extractPatternBinding,
+139 -586
View File
@@ -2,7 +2,6 @@ import type Parser from 'tree-sitter';
import type { NodeLabel } from 'gitnexus-shared';
import type { LanguageProvider } from '../language-provider.js';
import { generateId } from '../../../lib/utils.js';
import { extractSimpleTypeName } from '../type-extractors/shared.js';
/** Tree-sitter AST node. Re-exported for use across ingestion modules. */
export type SyntaxNode = Parser.SyntaxNode;
@@ -48,7 +47,13 @@ export const getDefinitionNodeFromCaptures = (
/**
* Node types that represent function/method definitions across languages.
* Used to find the enclosing function for a call site.
* Used by parent-walk in call-processor, parse-worker, and type-env to detect
* enclosing function scope boundaries.
*
* INVARIANT: This set MUST be a superset of every language's
* MethodExtractionConfig.methodNodeTypes. When adding a new node type to a
* MethodExtractor config, add it here too — otherwise enclosing-function
* resolution will silently miss that node type during parent-walks.
*/
export const FUNCTION_NODE_TYPES = new Set([
// TypeScript/JavaScript
@@ -92,18 +97,13 @@ export const FUNCTION_NODE_TYPES = new Set([
]);
/**
* Node types for standard function declarations that need C/C++ declarator handling.
* Used by extractFunctionName to determine how to extract the function name.
* AST node types that represent a class-like container (for HAS_METHOD edge extraction).
*
* INVARIANT: When a language config adds a new node type to `typeDeclarationNodes`,
* that type must also be added here AND to `CONTAINER_TYPE_TO_LABEL` below,
* otherwise `findEnclosingClassNode` won't recognize it and methods may get
* orphaned HAS_METHOD edges or incorrect labels.
*/
export const FUNCTION_DECLARATION_TYPES = new Set([
'function_declaration',
'function_definition',
'async_function_declaration',
'generator_function_declaration',
'function_item',
]);
/** AST node types that represent a class-like container (for HAS_METHOD edge extraction) */
export const CLASS_CONTAINER_TYPES = new Set([
'class_declaration',
'abstract_class_declaration',
@@ -118,10 +118,16 @@ export const CLASS_CONTAINER_TYPES = new Set([
'enum_item',
'class_definition',
'trait_declaration',
// PHP
'enum_declaration',
'protocol_declaration',
// Dart
'mixin_declaration',
'extension_declaration',
// Ruby
'class',
'module',
'singleton_class', // Ruby: class << self
// Kotlin
'object_declaration',
'companion_object',
@@ -140,28 +146,18 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
struct_item: 'Struct',
enum_item: 'Enum',
trait_declaration: 'Trait',
enum_declaration: 'Enum',
record_declaration: 'Record',
protocol_declaration: 'Interface',
mixin_declaration: 'Mixin',
extension_declaration: 'Extension',
class: 'Class',
module: 'Module',
singleton_class: 'Class', // Ruby: class << self inherits enclosing class name
object_declaration: 'Class',
companion_object: 'Class',
};
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
* Returns true when the captured definition node has a class_body ancestor. */
export function isKotlinClassMethod(
captureNode: { parent?: SyntaxNode | null } | null | undefined,
): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ancestor.type === 'class_body') return true;
ancestor = ancestor.parent;
}
return false;
}
/**
* Determine the graph node label from a tree-sitter capture map.
* Handles language-specific reclassification via the provider's labelOverride hook
@@ -206,33 +202,42 @@ export function getLabelFromCaptures(
return 'CodeElement';
}
/** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null.
/** Enclosing class info: both the generated node ID and the bare class name. */
export interface EnclosingClassInfo {
classId: string; // e.g. "Class:animal.dart:Animal"
className: string; // e.g. "Animal"
}
/** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name.
* For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). */
export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
export const findEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
): EnclosingClassInfo | null => {
let current = node.parent;
while (current) {
// Go: method_declaration has a receiver parameter with the struct type
if (current.type === 'method_declaration') {
const receiver = current.childForFieldName?.('receiver');
if (receiver) {
// receiver is a parameter_list: (u *User) or (u User)
const paramDecl = receiver.namedChildren?.find?.(
(c: SyntaxNode) => c.type === 'parameter_declaration',
);
if (paramDecl) {
const typeNode = paramDecl.childForFieldName?.('type');
if (typeNode) {
// Unwrap pointer_type (*User → User)
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
if (inner && (inner.type === 'type_identifier' || inner.type === 'identifier')) {
return generateId('Struct', `${filePath}:${inner.text}`);
return {
classId: generateId('Struct', `${filePath}:${inner.text}`),
className: inner.text,
};
}
}
}
}
}
// Go: type_declaration wrapping a struct_type (type User struct { ... })
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
if (current.type === 'type_declaration') {
const typeSpec = current.children?.find((c: SyntaxNode) => c.type === 'type_spec');
if (typeSpec) {
@@ -241,26 +246,66 @@ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string
const nameNode = typeSpec.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return generateId(label, `${filePath}:${nameNode.text}`);
return {
classId: generateId(label, `${filePath}:${nameNode.text}`),
className: nameNode.text,
};
}
}
}
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
// NOTE: This impl_item ownership logic is duplicated in rust.ts:extractOwnerName.
// If modifying this block, update the other location too.
if (current.type === 'impl_item') {
const children = current.children ?? [];
const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for');
if (forIdx !== -1) {
const nameNode = children
.slice(forIdx + 1)
.find((c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'identifier');
.find(
(c: SyntaxNode) =>
c.type === 'type_identifier' ||
c.type === 'scoped_type_identifier' ||
c.type === 'identifier',
);
if (nameNode) {
return generateId('Impl', `${filePath}:${nameNode.text}`);
return {
classId: generateId('Struct', `${filePath}:${nameNode.text}`),
className: nameNode.text,
};
}
}
// Fall through: plain `impl Struct {}` — use first type_identifier below
const firstType = children.find((c: SyntaxNode) => c.type === 'type_identifier');
if (firstType) {
return {
classId: generateId('Impl', `${filePath}:${firstType.text}`),
className: firstType.text,
};
}
}
// Ruby singleton_class (class << self): walk up to the enclosing class/module
// to inherit its name. singleton_class has no name field — its receiver is
// `self` (node type 'self'), not 'identifier' or 'constant'.
if (current.type === 'singleton_class') {
let ancestor = current.parent;
while (ancestor) {
if (ancestor.type === 'class' || ancestor.type === 'module') {
const classNameNode = ancestor.childForFieldName?.('name');
if (classNameNode) {
return {
classId: generateId('Class', `${filePath}:${classNameNode.text}`),
className: classNameNode.text,
};
}
}
ancestor = ancestor.parent;
}
// No enclosing class/module — skip singleton_class and keep walking up
}
const nameNode =
current.childForFieldName?.('name') ??
current.children?.find(
@@ -271,8 +316,21 @@ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string
c.type === 'constant',
);
if (nameNode) {
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
return generateId(label, `${filePath}:${nameNode.text}`);
let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
// Kotlin: class_declaration with an anonymous "interface" keyword child
// is actually an interface, not a class. Refine the label to match the
// node ID generated from the tree-sitter query capture (@definition.interface).
if (
current.type === 'class_declaration' &&
label === 'Class' &&
current.children?.some((c: SyntaxNode) => c.type === 'interface')
) {
label = 'Interface';
}
return {
classId: generateId(label, `${filePath}:${nameNode.text}`),
className: nameNode.text,
};
}
}
current = current.parent;
@@ -280,6 +338,11 @@ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string
return null;
};
/** Convenience wrapper: returns just the class ID string (backward compat). */
export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
return findEnclosingClassInfo(node, filePath)?.classId ?? null;
};
/**
* Find a child of `childType` within a sibling node of `siblingType`.
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
@@ -301,547 +364,49 @@ export const findSiblingChild = (
return null;
};
/**
* Extract function name and label from a function_definition or similar AST node.
* Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns.
*/
export const extractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } => {
let funcName: string | null = null;
let label: NodeLabel = 'Function';
// Swift init/deinit
if (node.type === 'init_declaration' || node.type === 'deinit_declaration') {
return {
funcName: node.type === 'init_declaration' ? 'init' : 'deinit',
label: 'Constructor',
};
/** Generic name extraction from a function-like AST node.
* Tries `node.childForFieldName('name')?.text`, then scans children for
* `identifier` / `property_identifier` / `simple_identifier`. */
export const genericFuncName = (node: SyntaxNode): string | null => {
const nameField = node.childForFieldName?.('name');
if (nameField) return nameField.text;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (
c?.type === 'identifier' ||
c?.type === 'property_identifier' ||
c?.type === 'simple_identifier'
)
return c.text;
}
if (FUNCTION_DECLARATION_TYPES.has(node.type)) {
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
let declarator = node.childForFieldName?.('declarator');
if (!declarator) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') {
declarator = c;
break;
}
}
}
while (
declarator &&
(declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')
) {
let nextDeclarator = declarator.childForFieldName?.('declarator');
if (!nextDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (
c?.type === 'function_declarator' ||
c?.type === 'pointer_declarator' ||
c?.type === 'reference_declarator'
) {
nextDeclarator = c;
break;
}
}
}
declarator = nextDeclarator;
}
if (declarator) {
let innerDeclarator = declarator.childForFieldName?.('declarator');
if (!innerDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (
c?.type === 'qualified_identifier' ||
c?.type === 'identifier' ||
c?.type === 'field_identifier' ||
c?.type === 'parenthesized_declarator'
) {
innerDeclarator = c;
break;
}
}
}
if (innerDeclarator?.type === 'qualified_identifier') {
let nameNode = innerDeclarator.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (
innerDeclarator?.type === 'identifier' ||
innerDeclarator?.type === 'field_identifier'
) {
// field_identifier is used for method names inside C++ class bodies
funcName = innerDeclarator.text;
if (innerDeclarator.type === 'field_identifier') label = 'Method';
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
let nestedId: SyntaxNode | null = null;
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') {
nestedId = c;
break;
}
}
if (nestedId?.type === 'qualified_identifier') {
let nameNode = nestedId.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < nestedId.childCount; i++) {
const c = nestedId.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (nestedId?.type === 'identifier') {
funcName = nestedId.text;
}
}
}
// Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier)
if (!funcName) {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (
c?.type === 'identifier' ||
c?.type === 'property_identifier' ||
c?.type === 'simple_identifier'
) {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text;
}
} else if (node.type === 'impl_item') {
let funcItem: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_item') {
funcItem = c;
break;
}
}
if (funcItem) {
let nameNode = funcItem.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < funcItem.childCount; i++) {
const c = funcItem.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text;
label = 'Method';
}
} else if (node.type === 'method_definition') {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'property_identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'arrow_function' || node.type === 'function_expression') {
const parent = node.parent;
if (parent?.type === 'variable_declarator') {
let nameNode = parent.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text;
}
} else if (node.type === 'method' || node.type === 'singleton_method') {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'function_signature') {
// Dart: top-level function signatures
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text ?? null;
} else if (node.type === 'method_signature') {
// Dart: method_signature wraps function_signature
let funcSig: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_signature') {
funcSig = c;
break;
}
}
if (funcSig) {
let nameNode = funcSig.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < funcSig.childCount; i++) {
const c = funcSig.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text ?? null;
}
label = 'Method';
}
return { funcName, label };
return null;
};
export interface MethodSignature {
parameterCount: number | undefined;
/** Number of required (non-optional, non-default) parameters.
* Only set when fewer than parameterCount — enables range-based arity filtering.
* undefined means all parameters are required (or metadata unavailable). */
requiredParameterCount: number | undefined;
/** Per-parameter type names extracted via extractSimpleTypeName.
* Only populated for languages with method overloading (Java, Kotlin, C#, C++).
* undefined (not []) when no types are extractable — avoids empty array allocations. */
parameterTypes: string[] | undefined;
returnType: string | undefined;
}
/** AST node types that represent a method definition (for `inferFunctionLabel`). */
export const METHOD_LABEL_NODE_TYPES = new Set([
'method_definition',
'method_declaration',
'method',
'singleton_method',
]);
/** Argument list node types shared between extractMethodSignature and countCallArguments. */
/** AST node types that represent a constructor definition (for `inferFunctionLabel`). */
export const CONSTRUCTOR_LABEL_NODE_TYPES = new Set([
'constructor_declaration',
'compact_constructor_declaration',
]);
/** Infer node label from AST node type for function-like nodes without a provider hook. */
export const inferFunctionLabel = (nodeType: string): NodeLabel =>
METHOD_LABEL_NODE_TYPES.has(nodeType)
? 'Method'
: CONSTRUCTOR_LABEL_NODE_TYPES.has(nodeType)
? 'Constructor'
: 'Function';
/** Argument list node types shared between countCallArguments and call-resolution helpers. */
export const CALL_ARGUMENT_LIST_TYPES = new Set(['arguments', 'argument_list', 'value_arguments']);
/**
* Extract parameter count and return type text from an AST method/function node.
* Works across languages by looking for common AST patterns.
*/
export const extractMethodSignature = (node: SyntaxNode | null | undefined): MethodSignature => {
let parameterCount: number | undefined = 0;
let requiredCount = 0;
let returnType: string | undefined;
let isVariadic = false;
const paramTypes: string[] = [];
if (!node)
return {
parameterCount,
requiredParameterCount: undefined,
parameterTypes: undefined,
returnType,
};
const paramListTypes = new Set([
'formal_parameters',
'parameters',
'parameter_list',
'function_parameters',
'method_parameters',
'function_value_parameters',
'formal_parameter_list', // Dart
]);
// Node types that indicate variadic/rest parameters
const VARIADIC_PARAM_TYPES = new Set([
'variadic_parameter_declaration', // Go: ...string
'variadic_parameter', // Rust: extern "C" fn(...)
'spread_parameter', // Java: Object... args
'list_splat_pattern', // Python: *args
'dictionary_splat_pattern', // Python: **kwargs
]);
/** AST node types that represent parameters with default values. */
const OPTIONAL_PARAM_TYPES = new Set([
'optional_parameter', // TypeScript, Ruby: (x?: number), (x: number = 5), def f(x = 5)
'default_parameter', // Python: def f(x=5)
'typed_default_parameter', // Python: def f(x: int = 5)
'optional_parameter_declaration', // C++: void f(int x = 5)
]);
/** Check if a parameter node has a default value (handles Kotlin, C#, Swift, PHP
* where defaults are expressed as child nodes rather than distinct node types). */
const hasDefaultValue = (paramNode: SyntaxNode): boolean => {
if (OPTIONAL_PARAM_TYPES.has(paramNode.type)) return true;
// C#, Swift, PHP: check for '=' token or equals_value_clause child
for (let i = 0; i < paramNode.childCount; i++) {
const c = paramNode.child(i);
if (!c) continue;
if (c.type === '=' || c.type === 'equals_value_clause') return true;
}
// Kotlin: default values are siblings of the parameter node, not children.
// The AST is: parameter, =, <literal> — all at function_value_parameters level.
// Check if the immediately following sibling is '=' (default value separator).
const sib = paramNode.nextSibling;
if (sib && sib.type === '=') return true;
return false;
};
const findParameterList = (current: SyntaxNode): SyntaxNode | null => {
for (const child of current.children) {
if (paramListTypes.has(child.type)) return child;
}
for (const child of current.children) {
const nested = findParameterList(child);
if (nested) return nested;
}
return null;
};
const parameterList = paramListTypes.has(node.type)
? node // node itself IS the parameter list (e.g. C# primary constructors)
: (node.childForFieldName?.('parameters') ?? findParameterList(node));
if (parameterList && paramListTypes.has(parameterList.type)) {
for (const param of parameterList.namedChildren) {
if (param.type === 'comment') continue;
if (
param.text === 'self' ||
param.text === '&self' ||
param.text === '&mut self' ||
param.type === 'self_parameter'
) {
continue;
}
// Kotlin: default values are siblings of the parameter node inside
// function_value_parameters, so they appear as named children (e.g.
// string_literal, integer_literal, boolean_literal, call_expression).
// Skip any named child that isn't a parameter-like or modifier node.
if (
param.type.endsWith('_literal') ||
param.type === 'call_expression' ||
param.type === 'navigation_expression' ||
param.type === 'prefix_expression' ||
param.type === 'parenthesized_expression'
) {
continue;
}
// Check for variadic parameter types
if (VARIADIC_PARAM_TYPES.has(param.type)) {
isVariadic = true;
continue;
}
// TypeScript/JavaScript: rest parameter — required_parameter containing rest_pattern
if (param.type === 'required_parameter' || param.type === 'optional_parameter') {
for (const child of param.children) {
if (child.type === 'rest_pattern') {
isVariadic = true;
break;
}
}
if (isVariadic) continue;
}
// Kotlin: vararg modifier on a regular parameter
if (param.type === 'parameter' || param.type === 'formal_parameter') {
const prev = param.previousSibling;
if (prev?.type === 'parameter_modifiers' && prev.text.includes('vararg')) {
isVariadic = true;
}
}
// Extract parameter type name for overload disambiguation.
// Works for Java (formal_parameter), Kotlin (parameter), C# (parameter),
// C++ (parameter_declaration). Uses childForFieldName('type') which is the
// standard tree-sitter field for typed parameters across these languages.
// Kotlin uses positional children instead of 'type' field — fall back to
// searching for user_type/nullable_type/predefined_type children.
const paramTypeNode = param.childForFieldName('type');
if (paramTypeNode) {
const typeName = extractSimpleTypeName(paramTypeNode);
paramTypes.push(typeName ?? 'unknown');
} else {
// Kotlin: parameter → [simple_identifier, user_type|nullable_type]
let found = false;
for (const child of param.namedChildren) {
if (
child.type === 'user_type' ||
child.type === 'nullable_type' ||
child.type === 'type_identifier' ||
child.type === 'predefined_type'
) {
const typeName = extractSimpleTypeName(child);
paramTypes.push(typeName ?? 'unknown');
found = true;
break;
}
}
if (!found) paramTypes.push('unknown');
}
if (!hasDefaultValue(param)) requiredCount++;
parameterCount++;
}
// C/C++: bare `...` token in parameter list (not a named child — check all children)
if (!isVariadic) {
for (const child of parameterList.children) {
if (!child.isNamed && child.text === '...') {
isVariadic = true;
break;
}
}
}
}
// Swift fallback: tree-sitter-swift places `parameter` nodes as direct children of
// function_declaration without a wrapping parameters/function_parameters list node.
// When no parameter list was found, count direct `parameter` children on the node.
if (!parameterList && parameterCount === 0) {
for (const child of node.namedChildren) {
if (child.type === 'parameter') {
if (!hasDefaultValue(child)) requiredCount++;
parameterCount++;
}
}
}
// Return type extraction — language-specific field names
// Go: 'result' field is either a type_identifier or parameter_list (multi-return)
const goResult = node.childForFieldName?.('result');
if (goResult) {
if (goResult.type === 'parameter_list') {
// Multi-return: extract first parameter's type only (e.g. (*User, error) → *User)
const firstParam = goResult.firstNamedChild;
if (firstParam?.type === 'parameter_declaration') {
const typeNode = firstParam.childForFieldName('type');
if (typeNode) returnType = typeNode.text;
} else if (firstParam) {
// Unnamed return types: (string, error) — first child is a bare type node
returnType = firstParam.text;
}
} else {
returnType = goResult.text;
}
}
// Rust: 'return_type' field — the value IS the type node (e.g. primitive_type, type_identifier).
// Skip if the node is a type_annotation (TS/Python), which is handled by the generic loop below.
if (!returnType) {
const rustReturn = node.childForFieldName?.('return_type');
if (rustReturn && rustReturn.type !== 'type_annotation') {
returnType = rustReturn.text;
}
}
// C/C++: 'type' field on function_definition
if (!returnType) {
const cppType = node.childForFieldName?.('type');
if (cppType && cppType.text !== 'void') {
returnType = cppType.text;
}
}
// C#: 'returns' field on method_declaration
if (!returnType) {
const csReturn = node.childForFieldName?.('returns');
if (csReturn && csReturn.text !== 'void') {
returnType = csReturn.text;
}
}
// TS/Rust/Python/C#/Kotlin: type_annotation or return_type child
if (!returnType) {
for (const child of node.children) {
if (child.type === 'type_annotation' || child.type === 'return_type') {
const typeNode = child.children.find((c) => c.isNamed);
if (typeNode) returnType = typeNode.text;
}
}
}
// Kotlin: fun getUser(): User — return type is a bare user_type child of
// function_declaration. The Kotlin grammar does NOT wrap it in type_annotation
// or return_type; it appears as a direct child after function_value_parameters.
// Note: Kotlin uses function_value_parameters (not a field), so we find it by type.
if (!returnType) {
let paramsEnd = -1;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'function_value_parameters' || child.type === 'value_parameters') {
paramsEnd = child.endIndex;
}
if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) {
returnType = child.text;
break;
}
}
}
if (isVariadic) parameterCount = undefined;
// Only include parameterTypes when at least one type was successfully extracted.
// Use undefined (not []) to avoid empty array allocations for untyped parameters.
const hasTypes = paramTypes.length > 0 && paramTypes.some((t) => t !== 'unknown');
// Only set requiredParameterCount when it differs from total — saves memory on the common case.
const requiredParameterCount =
!isVariadic && requiredCount < (parameterCount ?? 0) ? requiredCount : undefined;
return {
parameterCount,
requiredParameterCount,
parameterTypes: hasTypes ? paramTypes : undefined,
returnType,
};
};
// ============================================================================
// Generic AST traversal helpers (shared by parse-worker + php-helpers)
// ============================================================================
@@ -865,18 +430,6 @@ export function extractStringContent(node: SyntaxNode | null | undefined): strin
return null;
}
/** Check if a C/C++ function_definition is inside a class or struct body.
* Used by the C/C++ labelOverride to skip duplicate function captures
* that are already covered by definition.method queries. */
export function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}
return false;
}
/** Find the first direct named child of a tree-sitter node matching the given type. */
export function findChild(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
@@ -0,0 +1,38 @@
import type { MethodInfo } from '../method-types.js';
/**
* Compute arity for ID-generation purposes.
* Returns `undefined` when any parameter is variadic (arity is indeterminate).
*/
export function arityForIdFromInfo(info: MethodInfo): number | undefined {
return info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length;
}
/** Convert MethodInfo from methodExtractor into flat properties for a graph node. */
export function buildMethodProps(info: MethodInfo): Record<string, unknown> {
const types: string[] = [];
let optionalCount = 0;
let hasVariadic = false;
for (const p of info.parameters) {
if (p.type !== null) types.push(p.type);
if (p.isOptional) optionalCount++;
if (p.isVariadic) hasVariadic = true;
}
return {
parameterCount: hasVariadic ? undefined : info.parameters.length,
...(!hasVariadic && optionalCount > 0
? { requiredParameterCount: info.parameters.length - optionalCount }
: {}),
...(types.length > 0 ? { parameterTypes: types } : {}),
returnType: info.returnType ?? undefined,
visibility: info.visibility,
isStatic: info.isStatic,
isAbstract: info.isAbstract,
isFinal: info.isFinal,
...(info.isVirtual ? { isVirtual: info.isVirtual } : {}),
...(info.isOverride ? { isOverride: info.isOverride } : {}),
...(info.isAsync ? { isAsync: info.isAsync } : {}),
...(info.isPartial ? { isPartial: info.isPartial } : {}),
...(info.annotations.length > 0 ? { annotations: info.annotations } : {}),
};
}
@@ -0,0 +1,125 @@
/**
* Vue SFC (Single File Component) script extractor.
*
* Extracts the <script> / <script setup> block content from .vue files
* so it can be parsed by the TypeScript tree-sitter grammar.
*
* Pure function — no tree-sitter dependency, safe for worker threads.
*/
export interface VueScriptExtraction {
/** Extracted script content (TypeScript/JavaScript) */
scriptContent: string;
/** 0-based line number in the .vue file where the script content starts */
lineOffset: number;
/** true if the primary block is <script setup> */
isSetup: boolean;
}
interface ScriptBlock {
content: string;
lineOffset: number;
isSetup: boolean;
lang: string;
}
const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script>/g;
const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g;
// Greedy: matches from the first <template> to the *last* </template>.
// This is intentional — nested <template v-slot:...> tags are valid Vue
// syntax and we want the entire outermost template body.
const TEMPLATE_RE = /<template(\s[^>]*)?>([^]*)<\/template>/;
function countNewlines(text: string): number {
let count = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) count++;
}
return count;
}
function parseScriptBlock(
attrs: string | undefined,
content: string,
precedingText: string,
): ScriptBlock {
const isSetup = attrs != null && /\bsetup\b/.test(attrs);
const langMatch = attrs?.match(/\blang\s*=\s*["']([^"']+)["']/);
const lang = langMatch ? langMatch[1] : '';
// +1 for the newline after the opening <script...> tag
const lineOffset = countNewlines(precedingText) + 1;
return { content, lineOffset, isSetup, lang };
}
/**
* Extract script content from a Vue SFC.
*
* When both <script> and <script setup> are present, returns only the
* <script setup> block (the dominant pattern — 94% of Vue files in real
* projects use setup). The <script> (non-setup) block typically contains
* only `defineOptions` or legacy option merges and is less important for
* the knowledge graph.
*/
export function extractVueScript(vueContent: string): VueScriptExtraction | null {
const blocks: ScriptBlock[] = [];
let match: RegExpExecArray | null;
// Reset lastIndex for reuse of the global regex
SCRIPT_RE.lastIndex = 0;
while ((match = SCRIPT_RE.exec(vueContent)) !== null) {
const precedingText = vueContent.slice(0, match.index + match[0].indexOf(match[2]));
blocks.push(parseScriptBlock(match[1], match[2], precedingText));
}
if (blocks.length === 0) return null;
// Prefer <script setup> if present
const setupBlock = blocks.find((b) => b.isSetup);
const primary = setupBlock ?? blocks[0];
return {
scriptContent: primary.content,
lineOffset: primary.lineOffset,
isSetup: primary.isSetup,
};
}
/**
* Vue <script setup>: all top-level bindings are implicitly exported.
* Returns true if the node (or any ancestor) has the `program` root as its
* direct parent — i.e. the node is at the top level of the script block.
*
* Shared between the worker and sequential parsing paths.
*/
export const isVueSetupTopLevel = (
node: { parent: { type: string; parent: unknown } | null } | null,
): boolean => {
if (!node) return false;
let current: { parent: { type: string; parent: unknown } | null } | null = node;
while (current) {
if (current.parent?.type === 'program') return true;
current = current.parent as typeof current;
}
return false;
};
/**
* Extract PascalCase component names used in <template>.
* Returns deduplicated component names (e.g., ["MyButton", "AppHeader"]).
*/
export function extractTemplateComponents(vueContent: string): string[] {
const templateMatch = TEMPLATE_RE.exec(vueContent);
if (!templateMatch) return [];
const templateContent = templateMatch[2];
const components = new Set<string>();
let componentMatch: RegExpExecArray | null;
TEMPLATE_COMPONENT_RE.lastIndex = 0;
while ((componentMatch = TEMPLATE_COMPONENT_RE.exec(templateContent)) !== null) {
components.add(componentMatch[1]);
}
return [...components];
}
@@ -41,13 +41,15 @@ try {
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
FUNCTION_NODE_TYPES,
extractFunctionName,
getDefinitionNodeFromCaptures,
findEnclosingClassId,
findEnclosingClassInfo,
type EnclosingClassInfo,
getLabelFromCaptures,
extractMethodSignature,
findDescendant,
extractStringContent,
genericFuncName,
inferFunctionLabel,
CLASS_CONTAINER_TYPES,
type SyntaxNode,
} from '../utils/ast-helpers.js';
import {
@@ -65,11 +67,17 @@ import type { ConstructorBinding } from '../type-env.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
import { preprocessImportPath } from '../import-processor.js';
import {
extractVueScript,
extractTemplateComponents,
isVueSetupTopLevel,
} from '../vue-sfc-extractor.js';
import type { NamedBinding } from '../named-bindings/types.js';
import type { NodeLabel } from 'gitnexus-shared';
import type { FieldInfo, FieldExtractorContext } from '../field-types.js';
import type { MethodInfo, MethodExtractorContext } from '../method-types.js';
import { CLASS_CONTAINER_TYPES } from '../utils/ast-helpers.js';
import { buildMethodProps, arityForIdFromInfo } from '../utils/method-props.js';
import type { LanguageProvider } from '../language-provider.js';
// ============================================================================
// Types for serializable results
@@ -88,14 +96,8 @@ interface ParsedNode {
astFrameworkMultiplier?: number;
astFrameworkReason?: string;
description?: string;
parameterCount?: number;
requiredParameterCount?: number;
returnType?: string;
// Field/property metadata (populated by FieldExtractor)
declaredType?: string;
visibility?: string;
isStatic?: boolean;
isReadonly?: boolean;
// Method/field metadata — extensible via buildMethodProps spread
[key: string]: unknown;
};
}
@@ -283,6 +285,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
@@ -318,7 +321,7 @@ const setLanguage = (language: SupportedLanguages, filePath: string): void => {
// a stored null (enclosing class/function not found = top-level).
// ============================================================================
const classIdCache = new Map<SyntaxNode, string | null>();
const classIdCache = new Map<SyntaxNode, EnclosingClassInfo | null>();
const functionIdCache = new Map<SyntaxNode, string | null>();
const exportCache = new Map<SyntaxNode, boolean>();
@@ -345,6 +348,12 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Ruby singleton_class (class << self) has no name field — walk up to
// the enclosing class/module so the caller gets a node with a findable name.
if (current.type === 'singleton_class') {
current = current.parent;
continue;
}
return current;
}
current = current.parent;
@@ -352,6 +361,75 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
return null;
}
/**
* For C++ out-of-class method definitions (e.g. `void Foo::bar() {}`), extract the
* class name from the qualified_identifier scope and find the class declaration in the
* file's AST. Returns the class SyntaxNode or null if not found.
*
* Handles pointer/reference return types where function_declarator is nested inside
* pointer_declarator or reference_declarator.
*/
function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null {
const declarator = node.childForFieldName('declarator');
if (!declarator) return null;
// Find the function_declarator, recursively unwrapping pointer_declarator /
// reference_declarator chains (e.g. int** Foo::bar() has
// pointer_declarator → pointer_declarator → function_declarator).
let funcDecl: SyntaxNode | null = null;
if (declarator.type === 'function_declarator') {
funcDecl = declarator;
} else {
let current: SyntaxNode | null = declarator;
while (current && !funcDecl) {
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child?.type === 'function_declarator') {
funcDecl = child;
break;
}
}
if (!funcDecl) {
const next = current.namedChildren.find(
(c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator',
);
current = next ?? null;
}
}
}
if (!funcDecl) return null;
// Check if the inner declarator is a qualified_identifier (Foo::bar)
const innerDecl = funcDecl.childForFieldName('declarator');
if (!innerDecl || innerDecl.type !== 'qualified_identifier') return null;
const scope = innerDecl.childForFieldName('scope');
if (!scope) return null;
const className = scope.text;
// Search the file for a matching class/struct specifier, including inside
// namespace_definition blocks (the majority of production C++ uses namespaces).
const root = node.tree.rootNode;
const classTypes = new Set(['class_specifier', 'struct_specifier']);
const searchIn = (parent: SyntaxNode): SyntaxNode | null => {
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (!child) continue;
if (classTypes.has(child.type)) {
const nameNode = child.childForFieldName('name');
if (nameNode?.text === className) return child;
}
// Recurse into namespace blocks
if (child.type === 'namespace_definition') {
const found = searchIn(child);
if (found) return found;
}
}
return null;
};
return searchIn(root);
}
/**
* Minimal no-op SymbolTable stub for FieldExtractorContext in the worker.
* Field extraction only uses symbolTable.lookupExactAll for optional type resolution —
@@ -429,8 +507,6 @@ function getMethodInfo(
// Enclosing function detection (for call extraction) — cached
// ============================================================================
import type { LanguageProvider } from '../language-provider.js';
/** Walk up AST to find enclosing function, return its generateId or null for top-level.
* Applies provider.labelOverride so the label matches the definition phase (single source of truth). */
const findEnclosingFunctionId = (
@@ -444,7 +520,9 @@ const findEnclosingFunctionId = (
let current = node.parent;
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const { funcName, label } = extractFunctionName(current);
const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
const funcName = efnResult?.funcName ?? genericFuncName(current);
const label = efnResult?.label ?? inferFunctionLabel(current.type);
if (funcName) {
// Apply labelOverride so label matches definition phase (e.g., Kotlin Function→Method).
// null means "skip as definition" — keep original label for scope identification.
@@ -453,7 +531,31 @@ const findEnclosingFunctionId = (
const override = provider.labelOverride(current, label);
if (override !== null) finalLabel = override;
}
const result = generateId(finalLabel, `${filePath}:${funcName}`);
// Qualify with enclosing class to match definition-phase node IDs
const classInfo = cachedFindEnclosingClassInfo(current, filePath);
const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// Use the same MethodExtractor (getMethodInfo) as the definition phase.
let arity: number | undefined;
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
const classNode =
findEnclosingClassNode(current) ?? findClassNodeByQualifiedName(current);
if (classNode) {
const methodMap = getMethodInfo(classNode, provider, {
filePath,
language: getLanguageFromFilename(filePath),
});
const defLine = current.startPosition.row + 1;
const info = methodMap?.get(`${funcName}:${defLine}`);
if (info) {
arity = info.parameters.some((p) => p.isVariadic)
? undefined
: info.parameters.length;
}
}
}
const arityTag = arity !== undefined ? `#${arity}` : '';
const result = generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag}`);
functionIdCache.set(node, result);
return result;
}
@@ -469,7 +571,36 @@ const findEnclosingFunctionId = (
const override = provider.labelOverride(current.previousSibling, finalLabel);
if (override !== null) finalLabel = override;
}
const result = generateId(finalLabel, `${filePath}:${customResult.funcName}`);
// Qualify custom result with enclosing class
const classInfo = cachedFindEnclosingClassInfo(
current.previousSibling ?? current,
filePath,
);
const qualifiedName = classInfo
? `${classInfo.className}.${customResult.funcName}`
: customResult.funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
const sigNode = current.previousSibling ?? current;
let arity2: number | undefined;
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
const classNode2 =
findEnclosingClassNode(sigNode) ?? findClassNodeByQualifiedName(sigNode);
if (classNode2) {
const methodMap2 = getMethodInfo(classNode2, provider, {
filePath,
language: getLanguageFromFilename(filePath),
});
const defLine2 = sigNode.startPosition.row + 1;
const info2 = methodMap2?.get(`${customResult.funcName}:${defLine2}`);
if (info2) {
arity2 = info2.parameters.some((p) => p.isVariadic)
? undefined
: info2.parameters.length;
}
}
}
const arityTag2 = arity2 !== undefined ? `#${arity2}` : '';
const result = generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag2}`);
functionIdCache.set(node, result);
return result;
}
@@ -481,12 +612,15 @@ const findEnclosingFunctionId = (
return null;
};
/** Cached wrapper for findEnclosingClassId — avoids repeated parent walks. */
const cachedFindEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
/** Cached wrapper for findEnclosingClassInfo — avoids repeated parent walks. */
const cachedFindEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
): EnclosingClassInfo | null => {
const cached = classIdCache.get(node);
if (cached !== undefined) return cached;
const result = findEnclosingClassId(node, filePath);
const result = findEnclosingClassInfo(node, filePath);
classIdCache.set(node, result);
return result;
};
@@ -1143,12 +1277,24 @@ const processFileGroup = (
// Skip files larger than the max tree-sitter buffer (32 MB)
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
// Vue SFC preprocessing: extract <script> block content
let parseContent = file.content;
let lineOffset = 0;
let isVueSetup = false;
if (language === SupportedLanguages.Vue) {
const extracted = extractVueScript(file.content);
if (!extracted) continue; // skip .vue files with no script block
parseContent = extracted.scriptContent;
lineOffset = extracted.lineOffset;
isVueSetup = extracted.isSetup;
}
clearCaches(); // Reset memoization before each new file
let tree;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content.length),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent.length),
});
} catch (err) {
console.warn(
@@ -1204,6 +1350,7 @@ const processFileGroup = (
const typeEnv = buildTypeEnv(tree, language, {
parentMap,
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
});
const callRouter = provider.callRouter;
@@ -1301,7 +1448,7 @@ const processFileGroup = (
routePath,
httpMethod,
decoratorName,
lineNumber: decoratorNode.startPosition.row,
lineNumber: decoratorNode.startPosition.row + lineOffset,
});
}
// MCP/RPC tool detection: @mcp.tool(), @app.tool(), @server.tool()
@@ -1323,7 +1470,7 @@ const processFileGroup = (
result.fetchCalls.push({
filePath: file.path,
fetchURL: urlNode.text,
lineNumber: captureMap['route.fetch'].startPosition.row,
lineNumber: captureMap['route.fetch'].startPosition.row + lineOffset,
});
}
continue;
@@ -1339,7 +1486,7 @@ const processFileGroup = (
result.fetchCalls.push({
filePath: file.path,
fetchURL: url,
lineNumber: captureMap['http_client'].startPosition.row,
lineNumber: captureMap['http_client'].startPosition.row + lineOffset,
});
}
continue;
@@ -1363,7 +1510,7 @@ const processFileGroup = (
routePath,
httpMethod,
decoratorName: `express.${method}`,
lineNumber: captureMap['express_route'].startPosition.row,
lineNumber: captureMap['express_route'].startPosition.row + lineOffset,
});
}
continue;
@@ -1438,10 +1585,8 @@ const processFileGroup = (
}
if (routed.kind === 'properties') {
const propEnclosingClassId = cachedFindEnclosingClassId(
captureMap['call'],
file.path,
);
const propEnclosingInfo = cachedFindEnclosingClassInfo(captureMap['call'], file.path);
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
// Enrich routed properties with FieldExtractor metadata
let routedFieldMap: Map<string, FieldInfo> | undefined;
if (provider.fieldExtractor && typeEnv) {
@@ -1457,7 +1602,10 @@ const processFileGroup = (
}
for (const item of routed.items) {
const routedFieldInfo = routedFieldMap?.get(item.propName);
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
const propQualifiedName = propEnclosingInfo
? `${propEnclosingInfo.className}.${item.propName}`
: item.propName;
const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
result.nodes.push({
id: nodeId,
label: 'Property',
@@ -1646,11 +1794,77 @@ const processFileGroup = (
const nodeName = nameNode ? nameNode.text : 'init';
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNode
? definitionNode.startPosition.row
? definitionNode.startPosition.row + lineOffset
: nameNode
? nameNode.startPosition.row
: 0;
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
? nameNode.startPosition.row + lineOffset
: lineOffset;
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
const needsOwner =
nodeLabel === 'Method' ||
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
const enclosingClassInfo = needsOwner
? cachedFindEnclosingClassInfo(nameNode || definitionNode, file.path)
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
// Qualify method/property IDs with enclosing class name to avoid collisions
const qualifiedName = enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// Extract method metadata BEFORE generating node ID — parameterCount is needed
// to disambiguate overloaded methods via #<arity> suffix in the ID.
let declaredType: string | undefined;
let methodProps: Record<string, unknown> = {};
let arityForId: number | undefined; // raw param count for ID, even for variadic
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
// Use MethodExtractor for method metadata — provides parameterCount, parameterTypes,
// returnType, isAbstract/isFinal/annotations, visibility, and more.
let enrichedByMethodExtractor = false;
if (provider.methodExtractor && definitionNode) {
const classNode =
findEnclosingClassNode(definitionNode) ?? findClassNodeByQualifiedName(definitionNode);
if (classNode) {
const methodMap = getMethodInfo(classNode, provider, {
filePath: file.path,
language,
});
const defLine = definitionNode.startPosition.row + 1;
const info = methodMap?.get(`${nodeName}:${defLine}`);
if (info) {
enrichedByMethodExtractor = true;
arityForId = arityForIdFromInfo(info);
methodProps = buildMethodProps(info);
}
}
}
// For top-level methods (e.g. Go method_declaration), try extractFromNode
if (
!enrichedByMethodExtractor &&
provider.methodExtractor?.extractFromNode &&
definitionNode
) {
const info = provider.methodExtractor.extractFromNode(definitionNode, {
filePath: file.path,
language,
});
if (info) {
enrichedByMethodExtractor = true;
arityForId = arityForIdFromInfo(info);
methodProps = buildMethodProps(info);
}
}
}
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
// Functions are not suffixed — they don't overload by name in the same scope.
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
const arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap);
@@ -1684,7 +1898,7 @@ const processFileGroup = (
filePath: file.path,
toolName: nodeName,
description: dec.arg || '',
lineNumber: definitionNode.startPosition.row,
lineNumber: definitionNode.startPosition.row + lineOffset,
});
}
fileDecorators.delete(checkLine);
@@ -1692,82 +1906,8 @@ const processFileGroup = (
}
}
let parameterCount: number | undefined;
let requiredParameterCount: number | undefined;
let parameterTypes: string[] | undefined;
let returnType: string | undefined;
let declaredType: string | undefined;
let visibility: string | undefined;
let isStatic: boolean | undefined;
let isReadonly: boolean | undefined;
let isAbstract: boolean | undefined;
let isFinal: boolean | undefined;
let isVirtual: boolean | undefined;
let isOverride: boolean | undefined;
let isAsync: boolean | undefined;
let isPartial: boolean | undefined;
let annotations: string[] | undefined;
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
// Try MethodExtractor first — it provides everything extractMethodSignature does, plus
// isAbstract/isFinal/annotations. Only fall back to extractMethodSignature when no
// MethodExtractor is available or the method isn't inside a class body.
let enrichedByMethodExtractor = false;
if (provider.methodExtractor && definitionNode) {
const classNode = findEnclosingClassNode(definitionNode);
if (classNode) {
const methodMap = getMethodInfo(classNode, provider, {
filePath: file.path,
language,
});
const defLine = definitionNode.startPosition.row + 1;
const info = methodMap?.get(`${nodeName}:${defLine}`);
if (info) {
enrichedByMethodExtractor = true;
parameterCount = info.parameters.length;
const types: string[] = [];
let optionalCount = 0;
for (const p of info.parameters) {
if (p.type !== null) types.push(p.type);
if (p.isOptional) optionalCount++;
}
parameterTypes = types.length > 0 ? types : undefined;
requiredParameterCount =
optionalCount > 0 ? parameterCount - optionalCount : undefined;
returnType = info.returnType ?? undefined;
visibility = info.visibility;
isStatic = info.isStatic;
isAbstract = info.isAbstract;
isFinal = info.isFinal;
if (info.isVirtual) isVirtual = info.isVirtual;
if (info.isOverride) isOverride = info.isOverride;
if (info.isAsync) isAsync = info.isAsync;
if (info.isPartial) isPartial = info.isPartial;
if (info.annotations.length > 0) annotations = info.annotations;
}
}
}
if (!enrichedByMethodExtractor) {
const sig = extractMethodSignature(definitionNode);
parameterCount = sig.parameterCount;
requiredParameterCount = sig.requiredParameterCount;
parameterTypes = sig.parameterTypes;
returnType = sig.returnType;
}
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
if (
(!returnType || returnType === 'array' || returnType === 'iterable') &&
definitionNode
) {
const tc = provider.typeConfig;
if (tc?.extractReturnType) {
const docReturn = tc.extractReturnType(definitionNode);
if (docReturn) returnType = docReturn;
}
}
} else if (nodeLabel === 'Property' && definitionNode) {
// Property metadata extraction (not needed before nodeId — Properties don't overload)
if (nodeLabel === 'Property' && definitionNode) {
// FieldExtractor is the single source of truth when available
if (provider.fieldExtractor && typeEnv) {
const classNode = findEnclosingClassNode(definitionNode);
@@ -1781,9 +1921,9 @@ const processFileGroup = (
const info = fieldMap?.get(nodeName);
if (info) {
declaredType = info.type ?? undefined;
visibility = info.visibility;
isStatic = info.isStatic;
isReadonly = info.isReadonly;
methodProps.visibility = info.visibility;
methodProps.isStatic = info.isStatic;
methodProps.isReadonly = info.isReadonly;
}
}
}
@@ -1795,14 +1935,13 @@ const processFileGroup = (
properties: {
name: nodeName,
filePath: file.path,
startLine: definitionNode ? definitionNode.startPosition.row : startLine,
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
startLine: definitionNode ? definitionNode.startPosition.row + lineOffset : startLine,
endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine,
language: language,
isExported: cachedExportCheck(
provider.exportChecker,
nameNode || definitionNode,
nodeName,
),
isExported:
language === SupportedLanguages.Vue && isVueSetup
? isVueSetupTopLevel(nameNode || definitionNode)
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
@@ -1810,56 +1949,42 @@ const processFileGroup = (
}
: {}),
...(description !== undefined ? { description } : {}),
...(parameterCount !== undefined ? { parameterCount } : {}),
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(returnType !== undefined ? { returnType } : {}),
...methodProps,
...(declaredType !== undefined ? { declaredType } : {}),
...(visibility !== undefined ? { visibility } : {}),
...(isStatic !== undefined ? { isStatic } : {}),
...(isReadonly !== undefined ? { isReadonly } : {}),
...(isAbstract !== undefined ? { isAbstract } : {}),
...(isFinal !== undefined ? { isFinal } : {}),
...(isVirtual !== undefined ? { isVirtual } : {}),
...(isOverride !== undefined ? { isOverride } : {}),
...(isAsync !== undefined ? { isAsync } : {}),
...(isPartial !== undefined ? { isPartial } : {}),
...(annotations !== undefined ? { annotations } : {}),
},
});
// Compute enclosing class for Method/Constructor/Property/Function — used for both ownerId and HAS_METHOD
// Function is included because Kotlin/Rust/Python capture class methods as Function nodes
const needsOwner =
nodeLabel === 'Method' ||
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
const enclosingClassId = needsOwner
? cachedFindEnclosingClassId(nameNode || definitionNode, file.path)
: null;
// enclosingClassId already computed above (before nodeId generation)
result.symbols.push({
filePath: file.path,
name: nodeName,
nodeId,
type: nodeLabel,
...(parameterCount !== undefined ? { parameterCount } : {}),
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(returnType !== undefined ? { returnType } : {}),
parameterCount: methodProps.parameterCount as number | undefined,
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
parameterTypes: methodProps.parameterTypes as string[] | undefined,
returnType: methodProps.returnType as string | undefined,
...(declaredType !== undefined ? { declaredType } : {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
...(visibility !== undefined ? { visibility } : {}),
...(isStatic !== undefined ? { isStatic } : {}),
...(isReadonly !== undefined ? { isReadonly } : {}),
...(isAbstract !== undefined ? { isAbstract } : {}),
...(isFinal !== undefined ? { isFinal } : {}),
...(isVirtual !== undefined ? { isVirtual } : {}),
...(isOverride !== undefined ? { isOverride } : {}),
...(isAsync !== undefined ? { isAsync } : {}),
...(isPartial !== undefined ? { isPartial } : {}),
...(annotations !== undefined ? { annotations } : {}),
visibility: methodProps.visibility as string | undefined,
isStatic: methodProps.isStatic as boolean | undefined,
isReadonly: methodProps.isReadonly as boolean | undefined,
isAbstract: methodProps.isAbstract as boolean | undefined,
isFinal: methodProps.isFinal as boolean | undefined,
...(methodProps.isVirtual !== undefined
? { isVirtual: methodProps.isVirtual as boolean }
: {}),
...(methodProps.isOverride !== undefined
? { isOverride: methodProps.isOverride as boolean }
: {}),
...(methodProps.isAsync !== undefined ? { isAsync: methodProps.isAsync as boolean } : {}),
...(methodProps.isPartial !== undefined
? { isPartial: methodProps.isPartial as boolean }
: {}),
...(methodProps.annotations !== undefined
? { annotations: methodProps.annotations as string[] }
: {}),
});
const fileId = generateId('File', file.path);
@@ -1894,7 +2019,20 @@ const processFileGroup = (
}
// Extract ORM queries (Prisma, Supabase)
extractORMQueries(file.path, file.content, result.ormQueries);
extractORMQueries(file.path, parseContent, result.ormQueries);
// Vue: emit CALLS edges for components used in <template>
if (language === SupportedLanguages.Vue) {
const templateComponents = extractTemplateComponents(file.content);
for (const componentName of templateComponents) {
result.calls.push({
filePath: file.path,
calledName: componentName,
sourceId: generateId('File', file.path),
callForm: 'free',
});
}
}
}
};
+583
View File
@@ -0,0 +1,583 @@
/**
* LadybugDB connection pool (core). Used by MCP, sync, search, wiki, etc.
*
* LadybugDB Adapter (Connection Pool)
*
* Manages a pool of LadybugDB databases keyed by repoId, each with
* multiple Connection objects for safe concurrent query execution.
*
* LadybugDB Connections are NOT thread-safe — a single Connection
* segfaults if concurrent .query() calls hit it simultaneously.
* This adapter provides a checkout/return connection pool so each
* concurrent query gets its own Connection from the same Database.
*
* @see https://docs.ladybugdb.com/concurrency — multiple Connections
* from the same Database is the officially supported concurrency pattern.
*/
import fs from 'fs/promises';
import lbug from '@ladybugdb/core';
/** Per-repo pool: one Database, many Connections */
interface PoolEntry {
db: lbug.Database;
/** Available connections ready for checkout */
available: lbug.Connection[];
/** Number of connections currently checked out */
checkedOut: number;
/** Queued waiters for when all connections are busy */
waiters: Array<(conn: lbug.Connection) => void>;
lastUsed: number;
dbPath: string;
/** Set to true when the pool entry is closed — checkin will close orphaned connections */
closed: boolean;
}
const pool = new Map<string, PoolEntry>();
/**
* Shared Database cache keyed by resolved dbPath.
* Multiple repoIds pointing to the same path share one native Database
* object to avoid exhausting the buffer manager's mmap budget.
*/
interface SharedDB {
db: lbug.Database;
refCount: number;
ftsLoaded: boolean;
/** When true, closeOne skips db.close() — the Database is owned externally. */
external?: boolean;
}
const dbCache = new Map<string, SharedDB>();
/** Max repos in the pool (LRU eviction) */
const MAX_POOL_SIZE = 5;
/** Idle timeout before closing a repo's connections */
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Max connections per repo (caps concurrent queries per repo) */
const MAX_CONNS_PER_REPO = 8;
let idleTimer: ReturnType<typeof setInterval> | null = null;
/** Saved real stdout/stderr write — used to silence native module output without race conditions */
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
export const realStderrWrite = process.stderr.write.bind(process.stderr);
let stdoutSilenceCount = 0;
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
let preWarmActive = false;
/**
* Start the idle cleanup timer (runs every 60s)
*/
function ensureIdleTimer(): void {
if (idleTimer) return;
idleTimer = setInterval(() => {
const now = Date.now();
for (const [repoId, entry] of pool) {
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
closeOne(repoId);
}
}
}, 60_000);
if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
(idleTimer as NodeJS.Timeout).unref();
}
}
/**
* Touch a repo to reset its idle timeout.
* Call this during long-running operations to prevent the connection from being closed.
*/
export const touchRepo = (repoId: string): void => {
const entry = pool.get(repoId);
if (entry) {
entry.lastUsed = Date.now();
}
};
/**
* Evict the least-recently-used repo if pool is at capacity
*/
function evictLRU(): void {
if (pool.size < MAX_POOL_SIZE) return;
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, entry] of pool) {
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
oldestTime = entry.lastUsed;
oldestId = id;
}
}
if (oldestId) {
closeOne(oldestId);
}
}
/**
* Remove a repo from the pool, close its connections, and release its
* shared Database ref. Only closes the Database when no other repoIds
* reference it (refCount === 0).
*/
function closeOne(repoId: string): void {
const entry = pool.get(repoId);
if (!entry) return;
entry.closed = true;
// Close available connections — fire-and-forget with .catch() to prevent
// unhandled rejections. Native close() returns Promise<void> but can crash
// the N-API destructor on macOS/Windows; deferring to process exit lets
// dangerouslyIgnoreUnhandledErrors absorb the crash.
for (const conn of entry.available) {
conn.close().catch(() => {});
}
entry.available.length = 0;
// Checked-out connections can't be closed here — they're in-flight.
// The checkin() function detects entry.closed and closes them on return.
// Only close the Database when no other repoIds reference it.
// External databases (injected via initLbugWithDb) are never closed here —
// the core adapter owns them and handles their lifecycle.
const shared = dbCache.get(entry.dbPath);
if (shared) {
shared.refCount--;
if (shared.refCount === 0) {
if (shared.external) {
// External databases are owned by the core adapter — don't close
// or remove from cache. Keep the entry so future initLbug() calls
// for the same dbPath reuse it instead of hitting a file lock.
shared.refCount = 0;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
}
}
}
pool.delete(repoId);
}
/**
* Create a new Connection from a repo's Database.
* Silences stdout to prevent native module output from corrupting MCP stdio.
*/
let activeQueryCount = 0;
/**
* Silence stdout by replacing process.stdout.write with a no-op.
* Uses a reference counter so nested silence/restore pairs are safe.
* Exported so other modules (e.g. embedder) use the same mechanism instead
* of independently patching stdout, which causes restore-order conflicts.
*/
export function silenceStdout(): void {
if (stdoutSilenceCount++ === 0) {
process.stdout.write = (() => true) as any;
}
}
export function restoreStdout(): void {
if (--stdoutSilenceCount <= 0) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
// inside createConnection before restoreStdout runs).
// Exempts active queries and pre-warm — these legitimately hold silence for
// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s).
setInterval(() => {
if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}, 1000).unref();
function createConnection(db: lbug.Database): lbug.Connection {
silenceStdout();
try {
return new lbug.Connection(db);
} finally {
restoreStdout();
}
}
/** Query timeout in milliseconds */
const QUERY_TIMEOUT_MS = 30_000;
/** Waiter queue timeout in milliseconds */
const WAITER_TIMEOUT_MS = 15_000;
const LOCK_RETRY_ATTEMPTS = 3;
const LOCK_RETRY_DELAY_MS = 2000;
/** Deduplicates concurrent initLbug calls for the same repoId */
const initPromises = new Map<string, Promise<void>>();
/**
* Initialize (or reuse) a Database + connection pool for a specific repo.
* Retries on lock errors (e.g., when `gitnexus analyze` is running).
*
* Concurrent calls for the same repoId are deduplicated — the second caller
* awaits the first's in-progress init rather than starting a redundant one.
*/
export const initLbug = async (repoId: string, dbPath: string): Promise<void> => {
const existing = pool.get(repoId);
if (existing) {
existing.lastUsed = Date.now();
return;
}
// Deduplicate concurrent init calls for the same repoId —
// prevents double-init race when multiple parallel tool calls
// trigger initialization for the same repo simultaneously.
const pending = initPromises.get(repoId);
if (pending) return pending;
const promise = doInitLbug(repoId, dbPath);
initPromises.set(repoId, promise);
try {
await promise;
} finally {
initPromises.delete(repoId);
}
};
/**
* Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
* Pool entry is registered LAST so concurrent executeQuery calls see either
* "not initialized" (and throw) or a fully ready pool — never a half-built one.
*/
async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
// Check if database exists
try {
await fs.stat(dbPath);
} catch {
throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`);
}
evictLRU();
// Reuse an existing native Database if another repoId already opened this path.
// This prevents buffer manager exhaustion from multiple mmap regions on the same file.
let shared = dbCache.get(dbPath);
if (!shared) {
// Open in read-only mode — MCP server never writes to the database.
// This allows multiple MCP server instances to read concurrently, and
// avoids lock conflicts when `gitnexus analyze` is writing.
let lastError: Error | null = null;
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
silenceStdout();
try {
const db = new lbug.Database(
dbPath,
0, // bufferManagerSize (default)
false, // enableCompression (default)
true, // readOnly
);
restoreStdout();
shared = { db, refCount: 0, ftsLoaded: false };
dbCache.set(dbPath, shared);
break;
} catch (err: any) {
restoreStdout();
lastError = err instanceof Error ? err : new Error(String(err));
const isLockError =
lastError.message.includes('Could not set lock') || lastError.message.includes('lock');
if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break;
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
}
}
if (!shared) {
throw new Error(
`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
`Retry later. (${lastError?.message || 'unknown error'})`,
);
}
}
shared.refCount++;
const db = shared.db;
// Pre-create the full pool upfront so createConnection() (which silences
// stdout) is never called lazily during active query execution.
// Mark preWarmActive so the watchdog timer doesn't interfere.
preWarmActive = true;
const available: lbug.Connection[] = [];
try {
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
available.push(createConnection(db));
}
} finally {
preWarmActive = false;
}
// Load FTS extension once per shared Database.
// Done BEFORE pool registration so no concurrent checkout can grab
// the connection while the async FTS load is in progress.
if (!shared.ftsLoaded) {
try {
await available[0].query('LOAD EXTENSION fts');
shared.ftsLoaded = true;
} catch {
// Extension may not be installed — FTS queries will fail gracefully
}
}
// Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized"
// (and throw cleanly) or a fully ready pool — never a half-built one.
pool.set(repoId, {
db,
available,
checkedOut: 0,
waiters: [],
lastUsed: Date.now(),
dbPath,
closed: false,
});
ensureIdleTimer();
}
/**
* Initialize a pool entry from a pre-existing Database object.
*
* Used in tests to avoid the writable→close→read-only cycle that crashes
* on macOS due to N-API destructor segfaults. The pool adapter reuses
* the core adapter's writable Database instead of opening a new read-only one.
*
* The Database is registered in the shared dbCache so closeOne() decrements
* the refCount correctly. If the Database is already cached (e.g. another
* repoId already injected it), the existing entry is reused.
*/
export async function initLbugWithDb(
repoId: string,
existingDb: lbug.Database,
dbPath: string,
): Promise<void> {
const existing = pool.get(repoId);
if (existing) {
existing.lastUsed = Date.now();
return;
}
// Register in dbCache with external: true so other initLbug() calls
// for the same dbPath reuse this Database instead of trying to open
// a new one (which would fail with a file lock error).
// closeOne() respects the external flag and skips db.close().
let shared = dbCache.get(dbPath);
if (!shared) {
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
dbCache.set(dbPath, shared);
}
shared.refCount++;
const available: lbug.Connection[] = [];
preWarmActive = true;
try {
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
available.push(createConnection(existingDb));
}
} finally {
preWarmActive = false;
}
// Load FTS extension if not already loaded on this Database
try {
await available[0].query('LOAD EXTENSION fts');
} catch {
// Extension may already be loaded or not installed
}
pool.set(repoId, {
db: existingDb,
available,
checkedOut: 0,
waiters: [],
lastUsed: Date.now(),
dbPath,
closed: false,
});
ensureIdleTimer();
}
/**
* Checkout a connection from the pool.
* Returns an available connection, or creates a new one if under the cap.
* If all connections are busy and at cap, queues the caller until one is returned.
*/
function checkout(entry: PoolEntry): Promise<lbug.Connection> {
// Fast path: grab an available connection
if (entry.available.length > 0) {
entry.checkedOut++;
return Promise.resolve(entry.available.pop()!);
}
// Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
// with fewer total connections, something leaked — surface the bug rather
// than silently creating a connection (which would silence stdout mid-query).
const totalConns = entry.available.length + entry.checkedOut;
if (totalConns < MAX_CONNS_PER_REPO) {
throw new Error(
`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
`connections but found ${totalConns} (${entry.available.length} available, ` +
`${entry.checkedOut} checked out)`,
);
}
// At capacity — queue the caller with a timeout.
return new Promise<lbug.Connection>((resolve, reject) => {
const waiter = (conn: lbug.Connection) => {
clearTimeout(timer);
resolve(conn);
};
const timer = setTimeout(() => {
const idx = entry.waiters.indexOf(waiter);
if (idx !== -1) entry.waiters.splice(idx, 1);
reject(
new Error(
`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`,
),
);
}, WAITER_TIMEOUT_MS);
entry.waiters.push(waiter);
});
}
/**
* Return a connection to the pool after use.
* If the pool entry was closed while the connection was checked out (e.g.
* LRU eviction), close the orphaned connection instead of returning it.
* If there are queued waiters, hand the connection directly to the next one
* instead of putting it back in the available array (avoids race conditions).
*/
function checkin(entry: PoolEntry, conn: lbug.Connection): void {
if (entry.closed) {
// Pool entry was deleted during checkout — close the orphaned connection
conn.close().catch(() => {});
return;
}
if (entry.waiters.length > 0) {
// Hand directly to the next waiter — no intermediate available state
const waiter = entry.waiters.shift()!;
waiter(conn);
} else {
entry.checkedOut--;
entry.available.push(conn);
}
}
/**
* Execute a query on a specific repo's connection pool.
* Automatically checks out a connection, runs the query, and returns it.
*/
/** Race a promise against a timeout */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
if (isWriteQuery(cypher)) {
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
activeQueryCount++;
try {
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
activeQueryCount--;
restoreStdout();
checkin(entry, conn);
}
};
/**
* Execute a parameterized query on a specific repo's connection pool.
* Uses prepare/execute pattern to prevent Cypher injection.
*/
export const executeParameterized = async (
repoId: string,
cypher: string,
params: Record<string, any>,
): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
activeQueryCount++;
try {
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
activeQueryCount--;
restoreStdout();
checkin(entry, conn);
}
};
/**
* Close one or all repo pools.
* If repoId is provided, close only that repo's connections.
* If omitted, close all repos.
*/
export const closeLbug = async (repoId?: string): Promise<void> => {
if (repoId) {
closeOne(repoId);
return;
}
for (const id of [...pool.keys()]) {
closeOne(id);
}
if (idleTimer) {
clearInterval(idleTimer);
idleTimer = null;
}
};
/**
* Check if a specific repo's pool is active
*/
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
/** Regex to detect write operations in user-supplied Cypher queries.
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
* read-only mode as defense-in-depth against write procedures. */
export const CYPHER_WRITE_RE =
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
+1 -1
View File
@@ -73,7 +73,7 @@ export const searchFTSFromLbug = async (
// Use MCP connection pool via dynamic import
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
// The MCP pool supports multiple connections, but FTS is best run serially.
const { executeQuery } = await import('../../mcp/core/lbug-adapter.js');
const { executeQuery } = await import('../lbug/pool-adapter.js');
const executor = (cypher: string) => executeQuery(repoId, cypher);
fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit);
functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit);
@@ -46,6 +46,7 @@ const languageMap: Record<string, any> = {
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
+1 -1
View File
@@ -5,7 +5,7 @@
* Uses the MCP-style pooled lbug-adapter for connection management.
*/
import { initLbug, executeQuery, closeLbug, touchRepo } from '../../mcp/core/lbug-adapter.js';
import { initLbug, executeQuery, closeLbug, touchRepo } from '../lbug/pool-adapter.js';
const REPO_ID = '__wiki__';
+6 -4
View File
@@ -67,10 +67,12 @@ function buildHTML(
pages: Record<string, string>,
meta: Record<string, unknown> | null,
): string {
// Embed data as JSON inside the HTML
const pagesJSON = JSON.stringify(pages);
const treeJSON = JSON.stringify(moduleTree);
const metaJSON = JSON.stringify(meta);
// Embed data as JSON inside the HTML.
// Escape </script> sequences so they don't prematurely close the <script> tag.
const escScript = (s: string) => s.replace(/<\//g, '<\\/');
const pagesJSON = escScript(JSON.stringify(pages));
const treeJSON = escScript(JSON.stringify(moduleTree));
const metaJSON = escScript(JSON.stringify(meta));
const parts: string[] = [];
+5 -5
View File
@@ -152,11 +152,11 @@ export async function callLLM(
messages,
};
if (reasoning) {
body.max_completion_tokens = config.maxTokens;
// Do NOT include temperature, top_p, presence_penalty, frequency_penalty
} else {
body.max_tokens = config.maxTokens;
// max_tokens is deprecated; use max_completion_tokens for all models
body.max_completion_tokens = config.maxTokens;
// Only send temperature for non-Azure providers — some Azure models reject non-default values
if (!reasoning && !azure && config.temperature !== undefined) {
body.temperature = config.temperature;
}
+6 -5
View File
@@ -11,6 +11,7 @@ import {
getHttpDimensions,
httpEmbedQuery,
} from '../../core/embeddings/http-client.js';
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
// Model config
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
@@ -54,9 +55,9 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
// Silence stdout and stderr during model load — ONNX Runtime and transformers.js
// may write progress/init messages that corrupt MCP stdio protocol or produce
// noisy warnings (e.g. node assignment to execution providers).
const origStdout = process.stdout.write;
const origStderr = process.stderr.write;
process.stdout.write = (() => true) as any;
// Use the centralized silenceStdout() to avoid conflicts with pool-adapter's
// own stdout patching (independent patching caused restore-order bugs).
silenceStdout();
process.stderr.write = (() => true) as any;
try {
embedderInstance = await (pipeline as any)('feature-extraction', MODEL_ID, {
@@ -64,8 +65,8 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
dtype: 'fp32',
});
} finally {
process.stdout.write = origStdout;
process.stderr.write = origStderr;
restoreStdout();
process.stderr.write = realStderrWrite;
}
console.error(`GitNexus: Embedding model loaded (${device})`);
return embedderInstance!;
+3 -572
View File
@@ -1,574 +1,5 @@
/**
* LadybugDB Adapter (Connection Pool)
*
* Manages a pool of LadybugDB databases keyed by repoId, each with
* multiple Connection objects for safe concurrent query execution.
*
* LadybugDB Connections are NOT thread-safe — a single Connection
* segfaults if concurrent .query() calls hit it simultaneously.
* This adapter provides a checkout/return connection pool so each
* concurrent query gets its own Connection from the same Database.
*
* @see https://docs.ladybugdb.com/concurrency — multiple Connections
* from the same Database is the officially supported concurrency pattern.
* LadybugDB connection pool — re-exported from core.
* Prefer importing from `../../core/lbug/pool-adapter.js` in new code.
*/
import fs from 'fs/promises';
import lbug from '@ladybugdb/core';
/** Per-repo pool: one Database, many Connections */
interface PoolEntry {
db: lbug.Database;
/** Available connections ready for checkout */
available: lbug.Connection[];
/** Number of connections currently checked out */
checkedOut: number;
/** Queued waiters for when all connections are busy */
waiters: Array<(conn: lbug.Connection) => void>;
lastUsed: number;
dbPath: string;
/** Set to true when the pool entry is closed — checkin will close orphaned connections */
closed: boolean;
}
const pool = new Map<string, PoolEntry>();
/**
* Shared Database cache keyed by resolved dbPath.
* Multiple repoIds pointing to the same path share one native Database
* object to avoid exhausting the buffer manager's mmap budget.
*/
interface SharedDB {
db: lbug.Database;
refCount: number;
ftsLoaded: boolean;
/** When true, closeOne skips db.close() — the Database is owned externally. */
external?: boolean;
}
const dbCache = new Map<string, SharedDB>();
/** Max repos in the pool (LRU eviction) */
const MAX_POOL_SIZE = 5;
/** Idle timeout before closing a repo's connections */
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Max connections per repo (caps concurrent queries per repo) */
const MAX_CONNS_PER_REPO = 8;
let idleTimer: ReturnType<typeof setInterval> | null = null;
/** Saved real stdout.write — used to silence LadybugDB native output without race conditions */
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
let stdoutSilenceCount = 0;
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
let preWarmActive = false;
/**
* Start the idle cleanup timer (runs every 60s)
*/
function ensureIdleTimer(): void {
if (idleTimer) return;
idleTimer = setInterval(() => {
const now = Date.now();
for (const [repoId, entry] of pool) {
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
closeOne(repoId);
}
}
}, 60_000);
if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
(idleTimer as NodeJS.Timeout).unref();
}
}
/**
* Touch a repo to reset its idle timeout.
* Call this during long-running operations to prevent the connection from being closed.
*/
export const touchRepo = (repoId: string): void => {
const entry = pool.get(repoId);
if (entry) {
entry.lastUsed = Date.now();
}
};
/**
* Evict the least-recently-used repo if pool is at capacity
*/
function evictLRU(): void {
if (pool.size < MAX_POOL_SIZE) return;
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, entry] of pool) {
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
oldestTime = entry.lastUsed;
oldestId = id;
}
}
if (oldestId) {
closeOne(oldestId);
}
}
/**
* Remove a repo from the pool, close its connections, and release its
* shared Database ref. Only closes the Database when no other repoIds
* reference it (refCount === 0).
*/
function closeOne(repoId: string): void {
const entry = pool.get(repoId);
if (!entry) return;
entry.closed = true;
// Close available connections — fire-and-forget with .catch() to prevent
// unhandled rejections. Native close() returns Promise<void> but can crash
// the N-API destructor on macOS/Windows; deferring to process exit lets
// dangerouslyIgnoreUnhandledErrors absorb the crash.
for (const conn of entry.available) {
conn.close().catch(() => {});
}
entry.available.length = 0;
// Checked-out connections can't be closed here — they're in-flight.
// The checkin() function detects entry.closed and closes them on return.
// Only close the Database when no other repoIds reference it.
// External databases (injected via initLbugWithDb) are never closed here —
// the core adapter owns them and handles their lifecycle.
const shared = dbCache.get(entry.dbPath);
if (shared) {
shared.refCount--;
if (shared.refCount === 0) {
if (shared.external) {
// External databases are owned by the core adapter — don't close
// or remove from cache. Keep the entry so future initLbug() calls
// for the same dbPath reuse it instead of hitting a file lock.
shared.refCount = 0;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
}
}
}
pool.delete(repoId);
}
/**
* Create a new Connection from a repo's Database.
* Silences stdout to prevent native module output from corrupting MCP stdio.
*/
let activeQueryCount = 0;
function silenceStdout(): void {
if (stdoutSilenceCount++ === 0) {
process.stdout.write = (() => true) as any;
}
}
function restoreStdout(): void {
if (--stdoutSilenceCount <= 0) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
// inside createConnection before restoreStdout runs).
// Exempts active queries and pre-warm — these legitimately hold silence for
// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s).
setInterval(() => {
if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}, 1000).unref();
function createConnection(db: lbug.Database): lbug.Connection {
silenceStdout();
try {
return new lbug.Connection(db);
} finally {
restoreStdout();
}
}
/** Query timeout in milliseconds */
const QUERY_TIMEOUT_MS = 30_000;
/** Waiter queue timeout in milliseconds */
const WAITER_TIMEOUT_MS = 15_000;
const LOCK_RETRY_ATTEMPTS = 3;
const LOCK_RETRY_DELAY_MS = 2000;
/** Deduplicates concurrent initLbug calls for the same repoId */
const initPromises = new Map<string, Promise<void>>();
/**
* Initialize (or reuse) a Database + connection pool for a specific repo.
* Retries on lock errors (e.g., when `gitnexus analyze` is running).
*
* Concurrent calls for the same repoId are deduplicated — the second caller
* awaits the first's in-progress init rather than starting a redundant one.
*/
export const initLbug = async (repoId: string, dbPath: string): Promise<void> => {
const existing = pool.get(repoId);
if (existing) {
existing.lastUsed = Date.now();
return;
}
// Deduplicate concurrent init calls for the same repoId —
// prevents double-init race when multiple parallel tool calls
// trigger initialization for the same repo simultaneously.
const pending = initPromises.get(repoId);
if (pending) return pending;
const promise = doInitLbug(repoId, dbPath);
initPromises.set(repoId, promise);
try {
await promise;
} finally {
initPromises.delete(repoId);
}
};
/**
* Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
* Pool entry is registered LAST so concurrent executeQuery calls see either
* "not initialized" (and throw) or a fully ready pool — never a half-built one.
*/
async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
// Check if database exists
try {
await fs.stat(dbPath);
} catch {
throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`);
}
evictLRU();
// Reuse an existing native Database if another repoId already opened this path.
// This prevents buffer manager exhaustion from multiple mmap regions on the same file.
let shared = dbCache.get(dbPath);
if (!shared) {
// Open in read-only mode — MCP server never writes to the database.
// This allows multiple MCP server instances to read concurrently, and
// avoids lock conflicts when `gitnexus analyze` is writing.
let lastError: Error | null = null;
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
silenceStdout();
try {
const db = new lbug.Database(
dbPath,
0, // bufferManagerSize (default)
false, // enableCompression (default)
true, // readOnly
);
restoreStdout();
shared = { db, refCount: 0, ftsLoaded: false };
dbCache.set(dbPath, shared);
break;
} catch (err: any) {
restoreStdout();
lastError = err instanceof Error ? err : new Error(String(err));
const isLockError =
lastError.message.includes('Could not set lock') || lastError.message.includes('lock');
if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break;
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
}
}
if (!shared) {
throw new Error(
`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
`Retry later. (${lastError?.message || 'unknown error'})`,
);
}
}
shared.refCount++;
const db = shared.db;
// Pre-create the full pool upfront so createConnection() (which silences
// stdout) is never called lazily during active query execution.
// Mark preWarmActive so the watchdog timer doesn't interfere.
preWarmActive = true;
const available: lbug.Connection[] = [];
try {
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
available.push(createConnection(db));
}
} finally {
preWarmActive = false;
}
// Load FTS extension once per shared Database.
// Done BEFORE pool registration so no concurrent checkout can grab
// the connection while the async FTS load is in progress.
if (!shared.ftsLoaded) {
try {
await available[0].query('LOAD EXTENSION fts');
shared.ftsLoaded = true;
} catch {
// Extension may not be installed — FTS queries will fail gracefully
}
}
// Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized"
// (and throw cleanly) or a fully ready pool — never a half-built one.
pool.set(repoId, {
db,
available,
checkedOut: 0,
waiters: [],
lastUsed: Date.now(),
dbPath,
closed: false,
});
ensureIdleTimer();
}
/**
* Initialize a pool entry from a pre-existing Database object.
*
* Used in tests to avoid the writable→close→read-only cycle that crashes
* on macOS due to N-API destructor segfaults. The pool adapter reuses
* the core adapter's writable Database instead of opening a new read-only one.
*
* The Database is registered in the shared dbCache so closeOne() decrements
* the refCount correctly. If the Database is already cached (e.g. another
* repoId already injected it), the existing entry is reused.
*/
export async function initLbugWithDb(
repoId: string,
existingDb: lbug.Database,
dbPath: string,
): Promise<void> {
const existing = pool.get(repoId);
if (existing) {
existing.lastUsed = Date.now();
return;
}
// Register in dbCache with external: true so other initLbug() calls
// for the same dbPath reuse this Database instead of trying to open
// a new one (which would fail with a file lock error).
// closeOne() respects the external flag and skips db.close().
let shared = dbCache.get(dbPath);
if (!shared) {
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
dbCache.set(dbPath, shared);
}
shared.refCount++;
const available: lbug.Connection[] = [];
preWarmActive = true;
try {
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
available.push(createConnection(existingDb));
}
} finally {
preWarmActive = false;
}
// Load FTS extension if not already loaded on this Database
try {
await available[0].query('LOAD EXTENSION fts');
} catch {
// Extension may already be loaded or not installed
}
pool.set(repoId, {
db: existingDb,
available,
checkedOut: 0,
waiters: [],
lastUsed: Date.now(),
dbPath,
closed: false,
});
ensureIdleTimer();
}
/**
* Checkout a connection from the pool.
* Returns an available connection, or creates a new one if under the cap.
* If all connections are busy and at cap, queues the caller until one is returned.
*/
function checkout(entry: PoolEntry): Promise<lbug.Connection> {
// Fast path: grab an available connection
if (entry.available.length > 0) {
entry.checkedOut++;
return Promise.resolve(entry.available.pop()!);
}
// Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
// with fewer total connections, something leaked — surface the bug rather
// than silently creating a connection (which would silence stdout mid-query).
const totalConns = entry.available.length + entry.checkedOut;
if (totalConns < MAX_CONNS_PER_REPO) {
throw new Error(
`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
`connections but found ${totalConns} (${entry.available.length} available, ` +
`${entry.checkedOut} checked out)`,
);
}
// At capacity — queue the caller with a timeout.
return new Promise<lbug.Connection>((resolve, reject) => {
const waiter = (conn: lbug.Connection) => {
clearTimeout(timer);
resolve(conn);
};
const timer = setTimeout(() => {
const idx = entry.waiters.indexOf(waiter);
if (idx !== -1) entry.waiters.splice(idx, 1);
reject(
new Error(
`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`,
),
);
}, WAITER_TIMEOUT_MS);
entry.waiters.push(waiter);
});
}
/**
* Return a connection to the pool after use.
* If the pool entry was closed while the connection was checked out (e.g.
* LRU eviction), close the orphaned connection instead of returning it.
* If there are queued waiters, hand the connection directly to the next one
* instead of putting it back in the available array (avoids race conditions).
*/
function checkin(entry: PoolEntry, conn: lbug.Connection): void {
if (entry.closed) {
// Pool entry was deleted during checkout — close the orphaned connection
conn.close().catch(() => {});
return;
}
if (entry.waiters.length > 0) {
// Hand directly to the next waiter — no intermediate available state
const waiter = entry.waiters.shift()!;
waiter(conn);
} else {
entry.checkedOut--;
entry.available.push(conn);
}
}
/**
* Execute a query on a specific repo's connection pool.
* Automatically checks out a connection, runs the query, and returns it.
*/
/** Race a promise against a timeout */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
if (isWriteQuery(cypher)) {
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
activeQueryCount++;
try {
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
activeQueryCount--;
restoreStdout();
checkin(entry, conn);
}
};
/**
* Execute a parameterized query on a specific repo's connection pool.
* Uses prepare/execute pattern to prevent Cypher injection.
*/
export const executeParameterized = async (
repoId: string,
cypher: string,
params: Record<string, any>,
): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
activeQueryCount++;
try {
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
activeQueryCount--;
restoreStdout();
checkin(entry, conn);
}
};
/**
* Close one or all repo pools.
* If repoId is provided, close only that repo's connections.
* If omitted, close all repos.
*/
export const closeLbug = async (repoId?: string): Promise<void> => {
if (repoId) {
closeOne(repoId);
return;
}
for (const id of [...pool.keys()]) {
closeOne(id);
}
if (idleTimer) {
clearInterval(idleTimer);
idleTimer = null;
}
};
/**
* Check if a specific repo's pool is active
*/
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
/** Regex to detect write operations in user-supplied Cypher queries.
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
* read-only mode as defense-in-depth against write procedures. */
export const CYPHER_WRITE_RE =
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
export * from '../../core/lbug/pool-adapter.js';
+254 -14
View File
@@ -15,7 +15,7 @@ import {
closeLbug,
isLbugReady,
isWriteQuery,
} from '../core/lbug-adapter.js';
} from '../../core/lbug/pool-adapter.js';
export { isWriteQuery };
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
@@ -26,6 +26,7 @@ import {
cleanupOldKuzuFiles,
type RegistryEntry,
} from '../../storage/repo-manager.js';
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@@ -95,7 +96,9 @@ export const VALID_RELATION_TYPES = new Set([
'IMPLEMENTS',
'HAS_METHOD',
'HAS_PROPERTY',
'OVERRIDES',
'METHOD_OVERRIDES',
'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
'METHOD_IMPLEMENTS',
'ACCESSES',
'HANDLES_ROUTE',
'FETCHES',
@@ -116,7 +119,8 @@ export const VALID_RELATION_TYPES = new Set([
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
* EXTENDS – class hierarchy, statically verifiable → 0.85
* IMPLEMENTS – interface contract, statically verifiable → 0.85
* OVERRIDES – method override, statically verifiable → 0.85
* METHOD_OVERRIDES – method override, statically verifiable → 0.85
* METHOD_IMPLEMENTS – interface method implementation, statically verifiable → 0.85
* HAS_METHOD – structural containment → 0.95
* HAS_PROPERTY – structural containment → 0.95
* ACCESSES – field read/write, may be indirect → 0.8
@@ -128,7 +132,8 @@ export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
IMPORTS: 0.9,
EXTENDS: 0.85,
IMPLEMENTS: 0.85,
OVERRIDES: 0.85,
METHOD_OVERRIDES: 0.85,
METHOD_IMPLEMENTS: 0.85,
HAS_METHOD: 0.95,
HAS_PROPERTY: 0.95,
ACCESSES: 0.8,
@@ -175,6 +180,28 @@ export class LocalBackend {
private initializedRepos: Set<string> = new Set();
private reinitPromises: Map<string, Promise<void>> = new Map();
private lastStalenessCheck: Map<string, number> = new Map();
private groupToolSvc: GroupService | null = null;
/**
* Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers.
*/
getGroupService(): GroupService {
if (!this.groupToolSvc) {
const port: GroupToolPort = {
resolveRepo: (p) => this.resolveRepo(p),
impact: (r, p) => this.impact(r as RepoHandle, p),
query: (r, p) => this.query(r as RepoHandle, p),
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
};
this.groupToolSvc = new GroupService(port);
}
return this.groupToolSvc;
}
/** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */
async dispose(): Promise<void> {
await closeLbug();
}
// ─── Initialization ──────────────────────────────────────────────
@@ -430,6 +457,10 @@ export class LocalBackend {
return this.listRepos();
}
if (method.startsWith('group_')) {
return this.handleGroupTool(method, params || {});
}
// Resolve repo from optional param (re-reads registry on miss)
const repo = await this.resolveRepo(params?.repo);
@@ -1173,7 +1204,7 @@ export class LocalBackend {
repo.id,
`
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`,
@@ -1263,7 +1294,7 @@ export class LocalBackend {
repo.id,
`
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`,
@@ -1302,16 +1333,53 @@ export class LocalBackend {
return cats;
};
// Method/Function/Constructor enrichment: fetch method-specific properties
const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
const isMethodLike =
symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
let methodMetadata: Record<string, unknown> | undefined;
if (isMethodLike) {
try {
const metaRows = await executeParameterized(
repo.id,
`
MATCH (n {id: $symId})
RETURN n.visibility AS visibility, n.isStatic AS isStatic, n.isAbstract AS isAbstract,
n.isFinal AS isFinal, n.isVirtual AS isVirtual, n.isOverride AS isOverride,
n.isAsync AS isAsync, n.isPartial AS isPartial, n.returnType AS returnType,
n.parameterCount AS parameterCount, n.isVariadic AS isVariadic,
n.requiredParameterCount AS requiredParameterCount,
n.parameterTypes AS parameterTypes, n.annotations AS annotations
LIMIT 1
`,
{ symId },
);
if (metaRows.length > 0) {
const row = metaRows[0];
const meta: Record<string, unknown> = {};
// Only include defined properties to distinguish "not applicable" from "not enriched"
for (const key of Object.keys(row)) {
const val = row[key];
if (val !== null && val !== undefined) meta[key] = val;
}
if (Object.keys(meta).length > 0) methodMetadata = meta;
}
} catch {
/* method metadata unavailable — omit silently */
}
}
return {
status: 'found',
symbol: {
uid: sym.id || sym[0],
name: sym.name || sym[1],
kind: isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2],
kind: symKind,
filePath: sym.filePath || sym[3],
startLine: sym.startLine || sym[4],
endLine: sym.endLine || sym[5],
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
...(methodMetadata ? { methodMetadata } : {}),
},
incoming: categorize(incomingRows),
outgoing: categorize(outgoingRows),
@@ -1845,18 +1913,37 @@ export class LocalBackend {
const { target, direction } = params;
const maxDepth = params.maxDepth || 3;
// Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES)
const mappedRelTypes = params.relationTypes?.flatMap((t: string) =>
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
);
const rawRelTypes =
params.relationTypes && params.relationTypes.length > 0
? params.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t))
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
mappedRelTypes && mappedRelTypes.length > 0
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
: [
'CALLS',
'IMPORTS',
'EXTENDS',
'IMPLEMENTS',
'METHOD_OVERRIDES',
'OVERRIDES',
'METHOD_IMPLEMENTS',
];
const relationTypes =
rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
rawRelTypes.length > 0
? rawRelTypes
: [
'CALLS',
'IMPORTS',
'EXTENDS',
'IMPLEMENTS',
'METHOD_OVERRIDES',
'OVERRIDES',
'METHOD_IMPLEMENTS',
];
const includeTests = params.includeTests ?? false;
const minConfidence = params.minConfidence ?? 0;
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
// Resolve target by name, preferring Class/Interface over Constructor
// (fix #480: Java class and constructor share the same name).
// labels(n)[0] returns empty string in LadybugDB, so we use explicit
@@ -1917,6 +2004,33 @@ export class LocalBackend {
if (!sym) return { error: `Target '${target}' not found` };
return this._runImpactBFS(repo, sym, symType, direction, {
maxDepth,
relationTypes,
includeTests,
minConfidence,
});
}
/**
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
*/
private async _runImpactBFS(
repo: RepoHandle,
sym: any,
symType: string,
direction: 'upstream' | 'downstream',
opts: {
maxDepth: number;
relationTypes: string[];
includeTests: boolean;
minConfidence: number;
},
): Promise<any> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
const symId = sym.id || sym[0];
const impacted: any[] = [];
@@ -2323,6 +2437,132 @@ export class LocalBackend {
};
}
/**
* UID-based impact for cross-repo fan-out. Same result shape as `impact`.
* Returns null if the repo is unknown, the UID is missing, or analysis fails.
*/
async impactByUid(
repoId: string,
uid: string,
direction: string,
opts: {
maxDepth: number;
relationTypes: string[];
minConfidence: number;
includeTests: boolean;
},
): Promise<any | null> {
try {
await this.refreshRepos();
await this.ensureInitialized(repoId);
} catch {
return null;
}
const repo = this.repos.get(repoId);
if (!repo) return null;
const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream';
let rows: any[];
try {
rows = await executeParameterized(
repoId,
`MATCH (n) WHERE n.id = $uid
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type
LIMIT 1`,
{ uid },
);
} catch {
return null;
}
if (!rows?.length) return null;
const sym = rows[0];
const labelRaw = sym.type ?? sym[3];
const symType =
typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : '';
// Map legacy relation type names (backward compat for OVERRIDES → METHOD_OVERRIDES)
const mappedRelTypes = opts.relationTypes?.flatMap((t: string) =>
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
);
const rawRelTypes =
mappedRelTypes && mappedRelTypes.length > 0
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
: [
'CALLS',
'IMPORTS',
'EXTENDS',
'IMPLEMENTS',
'METHOD_OVERRIDES',
'OVERRIDES',
'METHOD_IMPLEMENTS',
];
const relationTypes =
rawRelTypes.length > 0
? rawRelTypes
: [
'CALLS',
'IMPORTS',
'EXTENDS',
'IMPLEMENTS',
'METHOD_OVERRIDES',
'OVERRIDES',
'METHOD_IMPLEMENTS',
];
try {
return await this._runImpactBFS(repo, sym, symType, dir, {
maxDepth: opts.maxDepth,
relationTypes,
includeTests: opts.includeTests,
minConfidence: opts.minConfidence,
});
} catch {
return null;
}
}
private handleGroupTool(method: string, params: Record<string, unknown>): Promise<unknown> {
switch (method) {
case 'group_list':
return this.groupList(params);
case 'group_sync':
return this.groupSync(params);
case 'group_contracts':
return this.groupContracts(params);
case 'group_query':
return this.groupQuery(params);
case 'group_status':
return this.groupStatus(params);
default:
throw new Error(`Unknown group tool: ${method}`);
}
}
private async groupList(params: Record<string, unknown>): Promise<unknown> {
return this.getGroupService().groupList(params);
}
private async groupSync(params: Record<string, unknown>): Promise<unknown> {
return this.getGroupService().groupSync(params);
}
private async groupContracts(params: Record<string, unknown>): Promise<unknown> {
return this.getGroupService().groupContracts(params);
}
private async groupQuery(params: Record<string, unknown>): Promise<unknown> {
await this.refreshRepos();
return this.getGroupService().groupQuery(params);
}
private async groupStatus(params: Record<string, unknown>): Promise<unknown> {
await this.refreshRepos();
return this.getGroupService().groupStatus(params);
}
/**
* Fetch Route nodes with their consumers in a single query.
* Shared by routeMap and shapeCheck to avoid N+1 query patterns.
+5 -4
View File
@@ -336,10 +336,10 @@ additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Nam
node_properties:
common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)"
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isVirtual (BOOL), isOverride (BOOL), isAsync (BOOL), isPartial (BOOL), requiredParameterCount (INT32), parameterTypes (STRING[]), annotations (STRING[])"
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isAsync (BOOL), parameterTypes (STRING[]), annotations (STRING[])"
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
Constructor: "parameterCount (INT32)"
Constructor: "parameterCount (INT32), visibility (STRING), isStatic (BOOL), parameterTypes (STRING[])"
Community: "heuristicLabel (STRING), cohesion (DOUBLE), symbolCount (INT32), keywords (STRING[]), description (STRING), enrichedBy (STRING)"
Process: "heuristicLabel (STRING), processType (STRING — 'intra_community' or 'cross_community'), stepCount (INT32), communities (STRING[]), entryPointId (STRING), terminalId (STRING)"
@@ -353,7 +353,8 @@ relationships:
- HAS_METHOD: Class/Struct/Interface owns a Method
- HAS_PROPERTY: Class/Struct/Interface owns a Property (field)
- ACCESSES: Function/Method reads or writes a Property (reason: 'read' or 'write')
- OVERRIDES: Method overrides another Method (MRO)
- METHOD_OVERRIDES: Method overrides another Method (MRO)
- METHOD_IMPLEMENTS: ConcreteMethod implements InterfaceMethod (matched by name + parameterTypes)
- MEMBER_OF: Symbol belongs to community
- STEP_IN_PROCESS: Symbol is step N in process

Some files were not shown because too many files have changed in this diff Show More