From d14d6602d54b46e14daa132ac904e65ea009b1fc Mon Sep 17 00:00:00 2001 From: evolution Date: Mon, 4 May 2026 14:29:11 +0800 Subject: [PATCH 01/12] feat(go): implement scope resolution hooks for Go language support (#1302) --- .../scope-resolution/finalize-algorithm.ts | 97 +++--- gitnexus/src/core/ingestion/languages/go.ts | 20 ++ .../ingestion/languages/go/arity-metadata.ts | 46 +++ .../src/core/ingestion/languages/go/arity.ts | 16 + .../ingestion/languages/go/cache-stats.ts | 18 ++ .../core/ingestion/languages/go/captures.ts | 168 +++++++++++ .../languages/go/expand-wildcards.ts | 99 ++++++ .../languages/go/import-decomposer.ts | 51 ++++ .../ingestion/languages/go/import-target.ts | 83 +++++ .../src/core/ingestion/languages/go/index.ts | 17 ++ .../ingestion/languages/go/interface-impls.ts | 87 ++++++ .../core/ingestion/languages/go/interpret.ts | 124 ++++++++ .../ingestion/languages/go/merge-bindings.ts | 27 ++ .../ingestion/languages/go/method-owners.ts | 108 +++++++ .../languages/go/namespace-mirror.ts | 59 ++++ .../languages/go/package-siblings.ts | 101 +++++++ .../src/core/ingestion/languages/go/query.ts | 211 +++++++++++++ .../ingestion/languages/go/range-binding.ts | 133 ++++++++ .../languages/go/receiver-binding.ts | 21 ++ .../ingestion/languages/go/scope-resolver.ts | 55 ++++ .../ingestion/languages/go/simple-hooks.ts | 38 +++ .../ingestion/languages/go/type-binding.ts | 285 ++++++++++++++++++ .../core/ingestion/registry-primary-flag.ts | 1 + .../src/core/ingestion/scope-extractor.ts | 4 + .../contract/scope-resolver.ts | 81 ++++- .../scope-resolution/graph-bridge/ids.ts | 1 - .../passes/free-call-fallback.ts | 53 ++++ .../passes/imported-return-types.ts | 2 +- .../ingestion/scope-resolution/passes/mro.ts | 3 +- .../passes/receiver-bound-calls.ts | 103 ++++--- .../scope-resolution/pipeline/registry.ts | 2 + .../scope-resolution/pipeline/run.ts | 30 +- .../scope/namespace-targets.ts | 11 +- .../scope-resolution/scope/walkers.ts | 21 ++ .../go-aliased-package-import/go.mod | 3 + .../internal/util/log.go | 3 + .../go-aliased-package-import/main.go | 7 + .../go-same-package-factory/go.mod | 3 + .../go-same-package-factory/main.go | 10 + .../go-same-package-factory/repo.go | 7 + .../go-same-package-factory/user.go | 7 + .../go-split-method-owner/go.mod | 3 + .../go-split-method-owner/main.go | 6 + .../go-split-method-owner/repo.go | 7 + .../go-split-method-owner/save.go | 5 + .../go-split-method-owner/user.go | 3 + .../test/integration/resolvers/go.test.ts | 70 ++++- .../test/integration/resolvers/helpers.ts | 7 + .../test/unit/registry-primary-flag.test.ts | 14 +- .../go/go-captures-smoke.test.ts | 65 ++++ .../unit/scope-resolution/go/go-hooks.test.ts | 130 ++++++++ .../scope-resolution/go/go-imports.test.ts | 154 ++++++++++ .../go/go-package-siblings.test.ts | 59 ++++ .../go/go-type-binding.test.ts | 114 +++++++ 54 files changed, 2750 insertions(+), 103 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/go/arity-metadata.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/arity.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/cache-stats.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/captures.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/import-decomposer.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/import-target.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/index.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/interface-impls.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/interpret.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/merge-bindings.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/method-owners.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/package-siblings.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/query.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/range-binding.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/receiver-binding.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/scope-resolver.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/simple-hooks.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/type-binding.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go create mode 100644 gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-imports.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index d363c3c97..a94012a0b 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -93,7 +93,7 @@ export interface FinalizeHooks { targetRaw: string, fromFile: string, workspaceIndex: WorkspaceIndex, - ): string | null; + ): string | readonly string[] | null; /** * For a wildcard `import * from M`, return the names visible in the @@ -127,20 +127,22 @@ export interface FinalizedScc { /** * Counters reported by `finalize`. * - * **Counting granularity** — all edge counters are **per-`ParsedImport`**, - * not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that - * expands to N exports counts as one linked edge in these stats; the - * materialized output (`FinalizeOutput.imports`) will have N edges for - * that input. `dynamic-unresolved` ParsedImports count as linked (they - * pass through with no `linkStatus`), so `linkedEdges` ≠ "has a + * **Counting granularity** — `totalEdges` is **per-generated-`ImportEdgeDraft`**, + * which may exceed the number of `ParsedImport` records when + * `resolveImportTarget` returns a multi-file array (e.g. Go package-scoped + * imports fan out to every `.go` file in the target directory). A single + * `wildcard` ParsedImport that expands to N exports also counts as one + * linked edge here; the materialized output (`FinalizeOutput.imports`) will + * have N edges for that input. `dynamic-unresolved` ParsedImports count as + * linked (they pass through with no `linkStatus`), so `linkedEdges` ≠ "has a * BindingRef" — use the `bindings` map for that. * - * In other words: `totalEdges === input.parsedImports.length` summed + * In other words: `totalEdges >= input.parsedImports.length` summed * across files, and `linkedEdges + unresolvedEdges === totalEdges`. */ export interface FinalizeStats { readonly totalFiles: number; - /** Total `ParsedImport` records seen across all files. */ + /** Total `ImportEdgeDraft` records generated (≥ ParsedImport count). */ readonly totalEdges: number; /** * `ParsedImport`s whose finalized edge does NOT carry @@ -179,9 +181,9 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu for (const file of input.files) { const drafts: ImportEdgeDraft[] = []; for (const parsed of file.parsedImports) { - const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex); - drafts.push(draft); - totalEdges++; + const draftArray = makeEdgeDrafts(parsed, file, hooks, input.workspaceIndex); + drafts.push(...draftArray); + totalEdges += draftArray.length; } edgeIndex.set(file.filePath, drafts); } @@ -320,12 +322,12 @@ interface ImportEdgeDraft { finalized: ImportEdge | null; } -function makeEdgeDraft( +function makeEdgeDrafts( parsed: ParsedImport, file: FinalizeFile, hooks: FinalizeHooks, workspace: WorkspaceIndex, -): ImportEdgeDraft { +): ImportEdgeDraft[] { // Dynamic-unresolved passes through — no `BindingRef`, no target file. if (parsed.kind === 'dynamic-unresolved') { const base: ImportEdge = { @@ -334,14 +336,16 @@ function makeEdgeDraft( targetExportedName: '', kind: 'dynamic-unresolved', }; - return { - source: parsed, - fromFile: file.filePath, - fromScope: file.moduleScope, - targetFile: null, - base, - finalized: base, // already fully finalized - }; + return [ + { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, // already fully finalized + }, + ]; } const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace); @@ -355,14 +359,16 @@ function makeEdgeDraft( kind: edgeKindFor(parsed), linkStatus: 'unresolved', }; - return { - source: parsed, - fromFile: file.filePath, - fromScope: file.moduleScope, - targetFile: null, - base, - finalized: base, - }; + return [ + { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, + }, + ]; } // Resolvable at the file level; intra-SCC fixpoint may still fail to fill @@ -370,21 +376,24 @@ function makeEdgeDraft( // and resolved-dynamic imports are terminal at the file level — no // `targetDefId` needed since they materialize no `BindingRef`. Pre- // finalize them here so the fixpoint loop skips them entirely. - const base: ImportEdge = { - localName: extractLocalName(parsed), - targetFile, - targetExportedName: extractExportedName(parsed), - kind: edgeKindFor(parsed), - }; + const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile]; const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved'; - return { - source: parsed, - fromFile: file.filePath, - fromScope: file.moduleScope, - targetFile, - base, - finalized: isFileLevelTerminal ? base : null, - }; + return targetFiles.map((tf) => { + const base: ImportEdge = { + localName: extractLocalName(parsed), + targetFile: tf, + targetExportedName: extractExportedName(parsed), + kind: edgeKindFor(parsed), + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: tf, + base, + finalized: isFileLevelTerminal ? base : null, + }; + }); } function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] { diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 64b9e359a..5c9aef039 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -29,6 +29,15 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { goCallConfig } from '../call-extractors/configs/go.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; import { goHeritageConfig } from '../heritage-extractors/configs/go.js'; +import { + emitGoScopeCaptures, + goArityCompatibility, + goBindingScopeFor, + goImportOwningScope, + goReceiverBinding, + interpretGoImport, + interpretGoTypeBinding, +} from './go/index.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, @@ -83,4 +92,15 @@ export const goProvider = defineLanguage({ variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), heritageExtractor: createHeritageExtractor(goHeritageConfig), + + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitGoScopeCaptures, + interpretImport: interpretGoImport, + interpretTypeBinding: interpretGoTypeBinding, + bindingScopeFor: goBindingScopeFor, + importOwningScope: goImportOwningScope, + receiverBinding: goReceiverBinding, + arityCompatibility: goArityCompatibility, + // resolveImportTarget lives on ScopeResolver (4-param signature), + // not on LanguageProvider (2-param signature). See go/scope-resolver.ts. }); diff --git a/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts new file mode 100644 index 000000000..958d0e70e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts @@ -0,0 +1,46 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export interface GoArityMetadata { + readonly parameterCount?: number; + readonly requiredParameterCount?: number; + readonly parameterTypes?: readonly string[]; +} + +export function computeGoDeclarationArity(node: SyntaxNode): GoArityMetadata { + const params = node.childForFieldName('parameters'); + if (params === null) return {}; + + let count = 0; + let required = 0; + const types: string[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null) continue; + if (param.type === 'parameter_declaration') { + const typeNode = param.childForFieldName('type'); + const typeName = typeNode === null ? '' : typeNode.text; + const names = param.namedChildren.filter((c) => c.type === 'identifier'); + const n = Math.max(1, names.length); + for (let j = 0; j < n; j++) { + count++; + required++; + types.push(typeName); + } + } + if (param.type === 'variadic_parameter_declaration') { + const typeNode = param.childForFieldName('type'); + const typeName = typeNode === null ? '...' : `...${typeNode.text}`; + count++; + types.push(typeName); + } + } + + return { parameterCount: count, requiredParameterCount: required, parameterTypes: types }; +} + +export function computeGoCallArity(callNode: SyntaxNode): number { + const args = callNode.childForFieldName('arguments'); + if (args === null) return 0; + return args.namedChildCount; +} diff --git a/gitnexus/src/core/ingestion/languages/go/arity.ts b/gitnexus/src/core/ingestion/languages/go/arity.ts new file mode 100644 index 000000000..6fc76661e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/arity.ts @@ -0,0 +1,16 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function goArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + const variadic = def.parameterTypes?.some((t) => t.startsWith('...')) ?? false; + if (min !== undefined && callsite.arity < min) return 'incompatible'; + if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible'; + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/go/cache-stats.ts b/gitnexus/src/core/ingestion/languages/go/cache-stats.ts new file mode 100644 index 000000000..cee1ec9aa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordGoCacheHit(): void { + hits++; +} +export function recordGoCacheMiss(): void { + misses++; +} + +export function getGoCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetGoCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts new file mode 100644 index 000000000..93fcbc51a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -0,0 +1,168 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getGoParser, getGoScopeQuery } from './query.js'; +import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js'; +import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js'; +import { splitGoImportStatement } from './import-decomposer.js'; +import { synthesizeGoReceiverBinding } from './receiver-binding.js'; +import { synthesizeGoTypeBindings } from './type-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; + +export function emitGoScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = getGoParser().parse(sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordGoCacheMiss(); + } else { + recordGoCacheHit(); + } + + const rawMatches = getGoScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; // skip anonymous captures + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const importNode = + findNodeAtRange(tree.rootNode, anchor.range, 'import_declaration') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'import_spec'); + if (importNode !== null) { + out.push(...splitGoImportStatement(importNode)); + continue; + } + } + + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = + findNodeAtRange(tree.rootNode, scopeCap.range, 'function_declaration') ?? + findNodeAtRange(tree.rootNode, scopeCap.range, 'method_declaration'); + if (fnNode !== null) { + const receiver = synthesizeGoReceiverBinding(fnNode); + if (receiver !== null) out.push(receiver); + } + } + + if (isRawMultiAssignTypeBinding(tree.rootNode, grouped)) continue; + + const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method']; + if (declAnchor !== undefined) { + const fnNode = + findNodeAtRange(tree.rootNode, declAnchor.range, 'function_declaration') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'method_declaration'); + if (fnNode !== null) { + const arity = computeGoDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + out.push(grouped); + continue; + } + + const callAnchor = + grouped['@reference.call.free'] ?? + grouped['@reference.call.member'] ?? + grouped['@reference.call.constructor']; + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = + findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'composite_literal'); + if (callNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(computeGoCallArity(callNode)), + ); + } + } + + out.push(grouped); + } + + // Layer on type-binding synthesis (new/make/qualified composite literal) + const synthesized = synthesizeGoTypeBindings(tree.rootNode); + out.push(...synthesized); + + // Synthesize typeBindings for struct fields so compound receiver + // resolution (`user.Address.Save()`) can walk field types. + for (const match of out) { + if (match['@declaration.field'] === undefined) continue; + const nameCap = match['@declaration.name']; + const typeCap = match['@declaration.field-type']; + if (nameCap === undefined || typeCap === undefined) continue; + // Create a synthetic @type-binding.field match using the field + // name and its declared type from the @declaration.field-type capture. + // This lands in the Class scope's typeBindings (via pass4 positioning). + out.push({ + '@type-binding.field': typeCap, + '@type-binding.name': nameCap, + '@type-binding.type': { + name: '@type-binding.type', + text: typeCap.text, + range: { ...typeCap.range }, + }, + }); + } + + return out; +} + +function isRawMultiAssignTypeBinding( + rootNode: SyntaxNode, + grouped: Record, +): boolean { + const anchor = + grouped['@type-binding.constructor'] ?? + grouped['@type-binding.call-return'] ?? + grouped['@type-binding.assertion']; + if (anchor === undefined) return false; + + const node = findNodeAtRange(rootNode, anchor.range, 'short_var_declaration'); + if (node === null) return false; + const lhs = node.childForFieldName('left'); + const rhs = node.childForFieldName('right'); + if (lhs === null) return false; + if (rhs === null) return false; + return ( + lhs.namedChildren.filter((c) => c.type === 'identifier').length >= 2 && + rhs.namedChildren.length >= 2 + ); +} diff --git a/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts b/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts new file mode 100644 index 000000000..767b43212 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts @@ -0,0 +1,99 @@ +import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +/** + * Expand Go dot imports (`import . "pkg"`) into binding augmentations. + * + * Go dot imports are treated as wildcard imports in the scope model. + * The shared `expandsWildcardTo` hook defaults to returning `[]` for Go + * because it can't easily access the target module's exported defs + * (it only receives a `ScopeId`). Instead we post-process wildcard + * import edges and augment bindings with the target file's exported + * (uppercase) defs — the same augmentation channel used by + * `populateGoPackageSiblings` for same-package cross-file visibility. + */ +export function expandGoDotImports( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): void { + const augmentations = indexes.bindingAugmentations as Map>; + + for (const parsed of parsedFiles) { + const moduleEdges = indexes.imports.get(parsed.moduleScope); + if (moduleEdges === undefined) continue; + + const wildcardTargets: string[] = []; + for (const edge of moduleEdges) { + // Go dot imports start as `kind: 'wildcard'`; finalize materializes + // them as `wildcard-expanded` import edges. + if (edge.kind !== 'wildcard-expanded' && edge.kind !== 'dynamic-resolved') { + continue; + } + if (edge.targetFile === null) continue; + if (!wildcardTargets.includes(edge.targetFile)) wildcardTargets.push(edge.targetFile); + } + if (wildcardTargets.length === 0) continue; + + for (const targetFile of wildcardTargets) { + const targetModule = indexes.moduleScopes.byFilePath.get(targetFile); + if (targetModule === undefined) continue; + + // Walk target module's local bindings — these are the exported symbols. + const targetBindings = indexes.bindings.get(targetModule); + if (targetBindings === undefined) continue; + + for (const [name, refs] of targetBindings) { + if (name.length === 0) continue; + // V1: ASCII-only export check; Unicode uppercase identifiers (e.g. Ñame) + // are not recognized as exported. Conforms to Go community convention. + const first = name[0]!; + if (first < 'A' || first > 'Z') continue; + + // Check if the importer already has this name. + const importerBindings = indexes.bindings.get(parsed.moduleScope); + if (importerBindings?.has(name)) continue; + + let augBucket = augmentations.get(parsed.moduleScope); + if (augBucket === undefined) { + augBucket = new Map(); + augmentations.set(parsed.moduleScope, augBucket); + } + + let entries = augBucket.get(name); + if (entries === undefined) { + entries = []; + augBucket.set(name, entries); + } + + for (const ref of refs) { + if (ref.origin !== 'local') continue; + if (entries.some((e) => e.def.nodeId === ref.def.nodeId)) continue; + entries.push({ def: ref.def, origin: 'wildcard' }); + } + } + } + } +} + +export function expandGoWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((parsed) => parsed.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const names: string[] = []; + for (const def of target.localDefs) { + const name = simpleName(def); + if (name === '') continue; + // V1: ASCII-only export check; see expandGoDotImports for full note. + const first = name[0]!; + if (first < 'A' || first > 'Z') continue; + if (!names.includes(name)) names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts new file mode 100644 index 000000000..7a20ca791 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts @@ -0,0 +1,51 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function splitGoImportStatement(node: SyntaxNode): CaptureMatch[] { + if (node.type === 'import_declaration') { + const out: CaptureMatch[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'import_spec') out.push(...splitGoImportStatement(child)); + if (child?.type === 'import_spec_list') { + for (let j = 0; j < child.namedChildCount; j++) { + const spec = child.namedChild(j); + if (spec?.type === 'import_spec') out.push(...splitGoImportStatement(spec)); + } + } + } + return out; + } + + if (node.type !== 'import_spec') return []; + const pathNode = node.childForFieldName('path'); + if (pathNode === null) return []; + + const rawPath = pathNode.text.replace(/^"|"$/g, '').replace(/^`|`$/g, ''); + const nameNode = node.childForFieldName('name'); + const alias = nameNode?.text; + const leaf = rawPath.split('/').filter(Boolean).pop() ?? rawPath; + const kind = + alias === '.' ? 'dot' : alias === '_' ? 'blank' : alias === undefined ? 'namespace' : 'alias'; + + // Blank imports (import _ "pkg") are dropped in V1 — they represent + // side-effect registrations (e.g. database drivers), but emitting + // side-effect edges is deferred. See test: go-imports.test.ts. + if (kind === 'blank') return []; + + const aliased = alias !== undefined && alias !== '.' && alias !== '_'; + return [ + { + '@import.statement': syntheticCapture('@import.statement', node, node.text), + '@import.kind': syntheticCapture('@import.kind', node, kind), + '@import.source': syntheticCapture('@import.source', pathNode, rawPath), + '@import.name': syntheticCapture( + '@import.name', + nameNode ?? pathNode, + aliased ? alias! : leaf, + ), + ...(aliased ? { '@import.alias': syntheticCapture('@import.alias', nameNode!, alias!) } : {}), + }, + ]; +} diff --git a/gitnexus/src/core/ingestion/languages/go/import-target.ts b/gitnexus/src/core/ingestion/languages/go/import-target.ts new file mode 100644 index 000000000..28e8113fd --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/import-target.ts @@ -0,0 +1,83 @@ +import type { GoModuleConfig } from '../../language-config.js'; + +/** + * Resolve a Go import path to ALL .go files in the matching package directory. + * + * Go packages are directory-scoped: one import statement brings in every + * (non-test) .go file in the package directory. Return all matching files so + * the shared finalize pass creates one ImportEdge per file — enabling both + * IMPORTS edge fanout AND binding materialization for every exported symbol in + * the package. + * + * Strategy (first match wins): + * 1. go.mod-based: strip module prefix, match package directory + * 2. Non-go.mod / GOPATH: progressively shorter directory suffixes + */ +export function resolveGoImportTarget( + targetRaw: string, + _fromFile: string, + allFilePaths: ReadonlySet, + resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + + const goModule = resolutionConfig as GoModuleConfig | undefined; + + // 1) go.mod-based: strip module prefix, match directory + if ( + goModule != null && + (targetRaw === goModule.modulePath || targetRaw.startsWith(`${goModule.modulePath}/`)) + ) { + const relativePkg = + targetRaw === goModule.modulePath ? '' : targetRaw.slice(goModule.modulePath.length + 1); // e.g. "internal/models" + const files = + relativePkg === '' + ? findRootPackageFiles(allFilePaths) + : findAllFilesInPkgDir(allFilePaths, relativePkg); + if (files.length > 0) return files; + } + + // 2) Non-go.mod / GOPATH: progressively shorter directory suffixes. + // "github.com/xxx/yyy/pkg" → try "github.com/xxx/yyy/pkg/" → "xxx/yyy/pkg/" → "yyy/pkg/" + // Stop at ≥2 segments to avoid matching a single-segment suffix (e.g. + // "pkg", "util", "internal") to a local directory with the same name. + const parts = targetRaw.split('/').filter(Boolean); + for (let i = 0; i < parts.length - 1; i++) { + const files = findAllFilesInPkgDir(allFilePaths, parts.slice(i).join('/')); + if (files.length > 0) return files; + } + + return null; +} + +function findRootPackageFiles(allFilePaths: ReadonlySet): string[] { + const result: string[] = []; + for (const raw of allFilePaths) { + const normalized = raw.replace(/\\/g, '/'); + if (normalized.includes('/')) continue; + if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; + result.push(raw); + } + return result.sort(); +} + +function findAllFilesInPkgDir(allFilePaths: ReadonlySet, pkgPath: string): string[] { + const pkgDir = '/' + pkgPath + '/'; + const result: string[] = []; + for (const raw of allFilePaths) { + const normalized = '/' + raw.replace(/\\/g, '/'); + if (!normalized.includes(pkgDir)) continue; + if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; + // Ensure file is directly in the package directory (not a subdirectory) + const afterPkg = normalized.substring(normalized.indexOf(pkgDir) + pkgDir.length); + if (!afterPkg.includes('/')) result.push(raw); + } + return result; +} + +/** Preserved for backward compat. */ +export interface GoResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; + readonly goModule?: GoModuleConfig; +} diff --git a/gitnexus/src/core/ingestion/languages/go/index.ts b/gitnexus/src/core/ingestion/languages/go/index.ts new file mode 100644 index 000000000..a71cba2fc --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/index.ts @@ -0,0 +1,17 @@ +/** + * Go scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitGoScopeCaptures } from './captures.js'; +export { getGoCaptureCacheStats, resetGoCaptureCacheStats } from './cache-stats.js'; +export { interpretGoImport, interpretGoTypeBinding, normalizeGoTypeName } from './interpret.js'; +export { splitGoImportStatement } from './import-decomposer.js'; +export { synthesizeGoReceiverBinding } from './receiver-binding.js'; +export { synthesizeGoTypeBindings } from './type-binding.js'; +export { goArityCompatibility } from './arity.js'; +export { goMergeBindings } from './merge-bindings.js'; +export { goBindingScopeFor, goImportOwningScope, goReceiverBinding } from './simple-hooks.js'; +export { resolveGoImportTarget, type GoResolveContext } from './import-target.js'; +export { populateGoPackageSiblings } from './package-siblings.js'; +export { populateGoRangeBindings } from './range-binding.js'; +export { detectGoInterfaceImplementations } from './interface-impls.js'; +export { mirrorGoNamespaceTypeBindings } from './namespace-mirror.js'; diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts new file mode 100644 index 000000000..bf21117a1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -0,0 +1,87 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { SemanticModel } from '../../model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +export function detectGoInterfaceImplementations( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + _model: SemanticModel, +): Map { + // 1. Collect interface defs → method names (from scope.ownedDefs) + const interfaceMethods = new Map>(); + const interfaceDefsById = new Map(); + + // 2. Collect struct defs → method names + const structMethods = new Map>(); + + for (const parsed of parsedFiles) { + // Collect interface defs and their owned methods + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + + // Find the type def for this scope + const typeDef = scope.ownedDefs.find((d) => d.type === 'Interface' || d.type === 'Struct'); + if (typeDef === undefined) continue; + + if (typeDef.type === 'Interface') { + interfaceDefsById.set(typeDef.nodeId, typeDef); + const methodNames = new Set(); + // Methods are in child scopes (Function kind) or ownedDefs + for (const childScope of parsed.scopes) { + if (childScope.parent === scope.id && childScope.kind === 'Function') { + for (const def of childScope.ownedDefs) { + if (def.type === 'Method' || def.type === 'Function') { + methodNames.add(def.qualifiedName?.split('.').pop() ?? ''); + } + } + } + } + // Also check if methods have ownerId pointing to this interface + for (const def of parsed.localDefs) { + if ( + (def as { ownerId?: string }).ownerId === typeDef.nodeId && + (def.type === 'Method' || def.type === 'Function') + ) { + methodNames.add(def.qualifiedName?.split('.').pop() ?? ''); + } + } + interfaceMethods.set(typeDef.nodeId, methodNames); + } + + if (typeDef.type === 'Struct') { + const methodNames = new Set(); + for (const def of parsed.localDefs) { + if ( + (def as { ownerId?: string }).ownerId === typeDef.nodeId && + (def.type === 'Method' || def.type === 'Function') + ) { + methodNames.add(def.qualifiedName?.split('.').pop() ?? ''); + } + } + structMethods.set(typeDef.nodeId, methodNames); + } + } + } + + // 3. For each interface, find structs whose method set is a superset + const impls = new Map(); + for (const [ifaceId, ifaceMethods] of interfaceMethods) { + if (ifaceMethods.size === 0) continue; + const implementors: string[] = []; + for (const [structId, methods] of structMethods) { + if (isSuperset(methods, ifaceMethods)) { + implementors.push(structId); + } + } + if (implementors.length > 0) impls.set(ifaceId, implementors); + } + + return impls; +} + +function isSuperset(superset: Set, subset: Set): boolean { + for (const item of subset) { + if (!superset.has(item)) return false; + } + return true; +} diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts new file mode 100644 index 000000000..c0bd71f48 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts @@ -0,0 +1,124 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +export function interpretGoImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + const source = captures['@import.source']?.text; + const name = captures['@import.name']?.text; + const alias = captures['@import.alias']?.text; + if (kind === undefined || source === undefined) return null; + + if (kind === 'dot') return { kind: 'wildcard', targetRaw: source }; + if (kind === 'alias') { + if (alias === undefined || name === undefined) return null; + return { kind: 'namespace', localName: alias, importedName: name, targetRaw: source }; + } + if (kind === 'namespace') { + if (name === undefined) return null; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + return null; +} + +export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + let normalizedType: string; + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.assertion'] !== undefined) { + source = 'annotation'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.new'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.field'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.range'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.index'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.multi-assign'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.make'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.return'] !== undefined) { + // Preserve dotted names for cross-package return-type chains. + source = 'return-annotation'; + normalizedType = normalizeGoReturnType(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeGoTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeGoTypeName(type); + } else { + normalizedType = normalizeGoTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +export function normalizeGoTypeName(text: string): string { + let t = text.trim(); + while (t.startsWith('*')) t = t.slice(1).trim(); + if (t.startsWith('[]')) t = t.slice(2).trim(); + const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/); + if (mapMatch) t = mapMatch[1].trim(); + t = t.replace(/^(?:<-)?chan\s+/, ''); + if (t.startsWith('func(')) { + const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/); + if (retMatch) t = retMatch[1].trim(); + } + const dot = t.lastIndexOf('.'); + if (dot !== -1) t = t.slice(dot + 1); + const bracket = t.indexOf('['); + if (bracket !== -1) t = t.slice(0, bracket); + return t; +} + +/** + * Like `normalizeGoTypeName` but preserves dotted package-prefix + * (`models.User` stays `models.User`). Used for return-type + * annotations so cross-package type chains can resolve through + * QualifiedNameIndex (which carries `pkg.Type` entries). + */ +export function normalizeGoReturnType(text: string): string { + let t = text.trim(); + // Multi-return syntax: (*T, error) → extract first type. Handles + // the common Go pattern where functions return (value, error). + if (t.startsWith('(') && t.includes(',')) { + const closeIdx = t.indexOf(','); + t = t.slice(1, closeIdx).trim(); + } + while (t.startsWith('*')) t = t.slice(1).trim(); + if (t.startsWith('[]')) t = t.slice(2).trim(); + const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/); + if (mapMatch) t = mapMatch[1].trim(); + t = t.replace(/^(?:<-)?chan\s+/, ''); + if (t.startsWith('func(')) { + const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/); + if (retMatch) t = retMatch[1].trim(); + } + // Preserve dotted qualified names for cross-package resolution. + const bracket = t.indexOf('['); + if (bracket !== -1) t = t.slice(0, bracket); + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts new file mode 100644 index 000000000..71d5031b2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts @@ -0,0 +1,27 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +export function goMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts new file mode 100644 index 000000000..457f9f60a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -0,0 +1,108 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; + +/** + * Populate `ownerId` on Go Method defs by matching receiver types + * extracted from `@type-binding.self` captures against struct defs in + * the module scope. + * + * Go method declarations are top-level (`func (r *T) M()`), not nested + * inside a struct body. The generic `populateClassOwnedMembers` requires + * the method's parent scope to be a `Class` scope, which never matches + * Go. This pass bridges the gap by reading the self typeBinding that + * `synthesizeGoReceiverBinding` creates, locating the matching struct + * def, and stamping `ownerId` onto the Method def. + */ +export function populateGoOwners(parsed: ParsedFile): void { + // 1. Standard nested-class pass — stamps ownerId on Property/Method defs + // inside Class scopes. With Class scopes now created for Go + // struct/interface declarations, this handles struct field ownership. + populateClassOwnedMembers(parsed); + + populateGoOwnersInPackage([parsed]); +} + +export function populateGoWorkspaceOwners( + parsedFiles: readonly ParsedFile[], + ctx: { readonly fileContents: ReadonlyMap }, +): void { + const filesByPackage = new Map(); + for (const parsed of parsedFiles) { + const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); + if (pkgName === null) continue; + const key = `${packageDir(parsed.filePath)}\0${pkgName}`; + const bucket = filesByPackage.get(key) ?? []; + bucket.push(parsed); + filesByPackage.set(key, bucket); + } + + for (const bucket of filesByPackage.values()) { + populateGoOwnersInPackage(bucket); + } +} + +function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void { + // Build struct name → def map from ALL scopes' ownedDefs (struct defs + // live in Class scopes now, not Module scope). + const structByQualifiedName = new Map(); // qname → nodeId + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + for (const def of scope.ownedDefs) { + if (isClassLike(def.type) && def.qualifiedName) { + structByQualifiedName.set(def.qualifiedName, def.nodeId); + } + } + } + } + + // 2. Go-specific method owner: each Method def lives in a Function + // scope whose typeBindings carry the self entry (kept there by + // goBindingScopeFor). Match the self rawName against struct defs. + if (structByQualifiedName.size > 0) { + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Function') continue; + const methodDefs = scope.ownedDefs.filter( + (d) => d.type === 'Method' && d.ownerId === undefined, + ); + if (methodDefs.length === 0) continue; + + // Find the self typeBinding in this Function scope. + let receiverType: string | undefined; + for (const [, tb] of scope.typeBindings) { + if (tb.source === 'self') { + receiverType = tb.rawName; + break; + } + } + if (receiverType === undefined) continue; + + let ownerId = structByQualifiedName.get(receiverType); + if (ownerId === undefined) { + for (const [qname, nodeId] of structByQualifiedName) { + if (qname.endsWith('.' + receiverType)) { + ownerId = nodeId; + break; + } + } + } + if (ownerId !== undefined) { + for (const def of methodDefs) { + (def as { ownerId?: string }).ownerId = ownerId; + } + } + } + } + } +} + +function inferPackageName(sourceText: string): string | null { + const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m); + return match?.[1] ?? null; +} + +function packageDir(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const idx = normalized.lastIndexOf('/'); + return idx === -1 ? '' : normalized.slice(0, idx); +} diff --git a/gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts b/gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts new file mode 100644 index 000000000..854e81f1e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts @@ -0,0 +1,59 @@ +import type { ParsedFile, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js'; +import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js'; + +/** + * Mirror exported typeBindings from namespace-import target modules + * into the importer's module scope. + * + * Go uses namespace imports (`import "pkg"`) where the target package's + * exported symbols are visible as `pkg.Func`. For cross-package return-type + * resolution to work, the importer needs the target package's exported + * typeBindings (e.g. `NewUser → User`) mirrored into its own module scope. + * + * Exported-symbol filter: Go uses uppercase first letter for exported names. + */ +export function mirrorGoNamespaceTypeBindings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + workspaceIndex: WorkspaceResolutionIndex, +): void { + const moduleScopeByFile = workspaceIndex.moduleScopeByFile; + + for (const parsed of parsedFiles) { + const importerModule = moduleScopeByFile.get(parsed.filePath); + if (importerModule === undefined) continue; + + const moduleEdges = indexes.imports.get(importerModule.id); + if (moduleEdges === undefined) continue; + + const nsTargets = new Map(); + for (const edge of moduleEdges) { + if (edge.kind !== 'namespace' || edge.targetFile === null) continue; + let targets = nsTargets.get(edge.localName); + if (targets === undefined) { + targets = []; + nsTargets.set(edge.localName, targets); + } + if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); + } + + for (const targetFiles of nsTargets.values()) { + for (const targetFile of targetFiles) { + const sourceModule = moduleScopeByFile.get(targetFile); + if (sourceModule === undefined) continue; + + for (const [name, ref] of sourceModule.typeBindings) { + if (name.length === 0) continue; + const first = name[0]!; + if (first < 'A' || first > 'Z') continue; + if (importerModule.typeBindings.has(name)) continue; + + const terminal = followChainPostFinalize(ref, sourceModule.id, indexes); + (importerModule.typeBindings as Map).set(name, terminal); + } + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts new file mode 100644 index 000000000..bcb0687dd --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts @@ -0,0 +1,101 @@ +import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +import { expandGoDotImports } from './expand-wildcards.js'; + +/** + * O(n²×d) where n = files per package, d = defs per file. + * Acceptable for V1 since Go packages are typically small (< 20 files). + * Future optimization: build a name→def inverted index per package to reduce + * to O(n×d). + */ +export function populateGoPackageSiblings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + ctx: { readonly fileContents: ReadonlyMap }, +): void { + // 0. Filter out test files — Go _test.go files should not contribute + // same-package sibling bindings to non-test files. + const nonTestFiles = parsedFiles.filter((f) => !f.filePath.endsWith('_test.go')); + + // 1. Expand dot imports first so subsequent same-package sibling + // augmentation can also see dot-imported names. + expandGoDotImports(nonTestFiles, indexes); + + // 2. Group files by package directory plus package name. Go package + // identity is directory-scoped; repeated `package main` directories + // must not see each other's unqualified names. + const packageByFile = new Map(); + for (const parsed of nonTestFiles) { + const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? ''); + if (pkgName !== null) { + packageByFile.set(parsed.filePath, `${packageDir(parsed.filePath)}\0${pkgName}`); + } + } + + const filesByPackage = new Map(); + for (const parsed of nonTestFiles) { + const pkgName = packageByFile.get(parsed.filePath); + if (pkgName === undefined) continue; + const list = filesByPackage.get(pkgName) ?? []; + list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs] }); + filesByPackage.set(pkgName, list); + } + + // 2. Use bindingAugmentations channel per I8 + const augmentations = indexes.bindingAugmentations as Map>; + + for (const [, siblings] of filesByPackage) { + for (const target of siblings) { + const targetModule = indexes.moduleScopes.byFilePath.get(target.filePath); + if (targetModule === undefined) continue; + + for (const receiver of siblings) { + if (receiver.filePath === target.filePath) continue; // no self-reference + const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath); + if (receiverModule === undefined) continue; + + for (const def of target.defs) { + // Go: same-package sibling files can see ALL names (both + // exported/uppercase and unexported/lowercase). Only cross- + // package visibility requires uppercase first letter. + const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (name === '') continue; + + const bucket = getAugmentationBucket(augmentations, receiverModule, name); + if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue; + bucket.push({ def, origin: 'namespace' }); + } + } + } + } +} + +function inferPackageName(sourceText: string): string | null { + const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m); + return match?.[1] ?? null; +} + +function packageDir(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const idx = normalized.lastIndexOf('/'); + return idx === -1 ? '' : normalized.slice(0, idx); +} + +function getAugmentationBucket( + augmentations: Map>, + scopeId: ScopeId, + name: string, +): BindingRef[] { + let scopeBindings = augmentations.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + augmentations.set(scopeId, scopeBindings); + } + let bucketArr = scopeBindings.get(name); + if (bucketArr === undefined) { + bucketArr = []; + scopeBindings.set(name, bucketArr); + } + return bucketArr; +} diff --git a/gitnexus/src/core/ingestion/languages/go/query.ts b/gitnexus/src/core/ingestion/languages/go/query.ts new file mode 100644 index 000000000..21aae19ad --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/query.ts @@ -0,0 +1,211 @@ +import Parser from 'tree-sitter'; +import Go from 'tree-sitter-go'; + +const GO_SCOPE_QUERY = ` +;; Scopes +(source_file) @scope.module +(type_declaration + (type_spec + type: [(struct_type) (interface_type)])) @scope.class +(function_declaration) @scope.function +(method_declaration) @scope.function +(func_literal) @scope.function +(block) @scope.block +(if_statement) @scope.block +(for_statement) @scope.block +(select_statement) @scope.block +(expression_switch_statement) @scope.block +(type_switch_statement) @scope.block +(expression_case) @scope.block +(default_case) @scope.block +(type_case) @scope.block +(communication_case) @scope.block + +;; Declarations — struct +(type_declaration + (type_spec name: (type_identifier) @declaration.name + type: (struct_type))) @declaration.struct + +;; Declarations — interface +(type_declaration + (type_spec name: (type_identifier) @declaration.name + type: (interface_type))) @declaration.interface + +;; Declarations — function +(function_declaration + name: (identifier) @declaration.name) @declaration.function + +;; Declarations — method +(method_declaration + name: (field_identifier) @declaration.name) @declaration.method + +;; Declarations — struct fields +(struct_type + (field_declaration_list + (field_declaration + name: (field_identifier) @declaration.name + type: (_) @declaration.field-type))) @declaration.field + +;; Declarations — variables +(var_declaration + (var_spec + name: (identifier) @declaration.name)) @declaration.variable + +(const_declaration + (const_spec + name: (identifier) @declaration.name)) @declaration.const + +(short_var_declaration + left: (expression_list (identifier) @declaration.name)) @declaration.variable + +;; Imports +(import_spec) @import.statement + +;; Type bindings — parameter annotations +(function_declaration + name: (identifier) @_fn_name + parameters: (parameter_list + (parameter_declaration + name: (identifier) @type-binding.name + type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter + +(method_declaration + name: (field_identifier) @_fn_name + parameters: (parameter_list + (parameter_declaration + name: (identifier) @type-binding.name + type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter + +;; Type bindings — constructor-inferred (:= T{}) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list + (composite_literal + type: [(type_identifier) (qualified_type)] @type-binding.type))) @type-binding.constructor + +;; Type bindings — pointer constructor (:= &T{}) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list + (unary_expression + "&" + operand: (composite_literal + type: [(type_identifier) (qualified_type)] @type-binding.type)))) @type-binding.constructor + +;; Type bindings — type assertion (:= s.(T)) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list + (type_assertion_expression + type: (_) @type-binding.type))) @type-binding.assertion + +(var_declaration + (var_spec + name: (identifier) @type-binding.name + value: (expression_list + (type_assertion_expression + type: (_) @type-binding.type)))) @type-binding.assertion + +;; Type bindings — explicit var type +(var_declaration + (var_spec + name: (identifier) @type-binding.name + type: (_) @type-binding.type)) @type-binding.assignment + +;; Type bindings — call-return inference (:= Func(args)) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list (call_expression + function: (identifier) @type-binding.type))) @type-binding.call-return + +;; Type bindings — call-return inference qualified (:= pkg.Func(args)) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list (call_expression + function: (selector_expression + field: (field_identifier) @type-binding.type)))) @type-binding.call-return + +;; Type bindings — return type annotation (func Foo() *Type) +(function_declaration + name: (identifier) @type-binding.name + result: (_) @type-binding.type) @type-binding.return + +;; Type bindings — method return type (func (r *T) Method() *Type) +(method_declaration + name: (field_identifier) @type-binding.name + result: (_) @type-binding.type) @type-binding.return + +;; Type bindings — variable alias (y := x) +(short_var_declaration + left: (expression_list (identifier) @type-binding.name) + right: (expression_list (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — variable alias var form (var x = y) +(var_declaration + (var_spec + name: (identifier) @type-binding.name + value: (expression_list (identifier) @type-binding.type))) @type-binding.alias + +;; Type bindings — call-return var form (var x = Func()) +(var_declaration + (var_spec + name: (identifier) @type-binding.name + value: (expression_list (call_expression + function: (identifier) @type-binding.type)))) @type-binding.call-return + +;; References — free calls +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls +(call_expression + function: (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; References — constructor calls (T{}) +(composite_literal + type: [(type_identifier) (qualified_type)] @reference.name) @reference.call.constructor + +;; References — field reads +(selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; References — field writes (assignment) +(assignment_statement + left: (expression_list + (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name))) @reference.write + +;; References — field writes (inc: obj.Field++) +(inc_statement + (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write + +;; References — field writes (dec: obj.Field--) +(dec_statement + (selector_expression + operand: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getGoParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Go as Parameters[0]); + } + return _parser; +} + +export function getGoScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Go as Parameters[0], GO_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts new file mode 100644 index 000000000..d95676480 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts @@ -0,0 +1,133 @@ +import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getGoParser } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; + +export function populateGoRangeBindings( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const parser = getGoParser(); + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parser.parse(sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); + + for (const rangeNode of tree.rootNode.descendantsOfType('for_statement')) { + const rangeClause = rangeNode.namedChildren.find((c) => c.type === 'range_clause'); + if (rangeClause === null) continue; + + const left = rangeClause.namedChildren.find((c) => c.type === 'expression_list'); + if (left === null) continue; + + const rangeExpr = rangeClause.namedChildren.find( + (c, idx) => c.type !== 'expression_list' && idx > rangeClause.namedChildren.indexOf(left), + ); + if (rangeExpr === undefined) continue; + + // Identify the value variable (skip the `_` discard if present) + const idents = left.namedChildren.filter((c) => c.type === 'identifier'); + let valueVar: string | null = null; + if (idents.length >= 2) { + valueVar = idents[1].text; // for _, v := range ... + } else if (idents.length === 1) { + valueVar = idents[0].text; // for v := range ... + } + + if (valueVar === null || valueVar === '_') continue; + + // Resolve range expression type + let elementType: string | null = null; + + if (rangeExpr.type === 'identifier') { + // Look up the identifier's type in scope typeBindings (V1: module scope only) + const binding = moduleScope.typeBindings.get(rangeExpr.text); + if (binding !== null && binding !== undefined) { + elementType = extractElementType(binding); + } + } else if (rangeExpr.type === 'call_expression') { + const fnNode = rangeExpr.childForFieldName('function'); + if (fnNode !== null) { + const fnName = + fnNode.type === 'selector_expression' + ? fnNode.childForFieldName('field')?.text + : fnNode.text; + if (fnName !== undefined) { + const binding = moduleScope.typeBindings.get(fnName); + if (binding !== null && binding !== undefined) { + elementType = extractElementType(binding); + } + } + } + } + + if (elementType !== null && valueVar !== null) { + // Inject type binding for the range variable onto the enclosing function scope + const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap); + const targetScope = functionScope ?? moduleScope; + const mutable = targetScope.typeBindings as Map; + mutable.set(valueVar, { + rawName: elementType, + declaredAtScope: targetScope.id, + source: 'annotation', + }); + } + } + } +} + +function extractElementType(binding: TypeRef): string | null { + const raw = binding.rawName; + const mapMatch = raw.match(/^map\[[^\]]+\]\s*(.+)$/); + if (mapMatch) return mapMatch[1].trim(); + if (raw.startsWith('[]')) return raw.slice(2).trim(); + const arrMatch = raw.match(/^\[\d+\](.+)$/); + if (arrMatch) return arrMatch[1].trim(); + return raw; +} + +function findEnclosingFunctionScope( + node: unknown, + scopeMap: ReadonlyMap, +): Scope | null { + const tsNode = node as { + readonly parent: unknown; + readonly type: string; + readonly startPosition: { readonly row: number; readonly column: number }; + }; + // Walk up the AST to find the enclosing function or method declaration. + let current: typeof tsNode | null = tsNode; + while (current !== null) { + if (current.type === 'function_declaration' || current.type === 'method_declaration') { + // Match by source position: the scope whose range starts at the + // same line/column as the tree-sitter node. + for (const scope of scopeMap.values()) { + if ( + scope.kind === 'Function' && + scope.range.startLine === current.startPosition.row && + scope.range.startCol === current.startPosition.column + ) { + return scope; + } + } + break; + } + current = (current.parent as typeof tsNode) ?? null; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts new file mode 100644 index 000000000..d5089fa00 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts @@ -0,0 +1,21 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function synthesizeGoReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null { + if (fnNode.type !== 'method_declaration') return null; + const receiver = fnNode.childForFieldName('receiver'); + if (receiver === null) return null; + const param = receiver.namedChildren.find((c) => c.type === 'parameter_declaration'); + if (param === undefined) return null; + const nameNode = param.childForFieldName('name'); + const typeNode = param.childForFieldName('type'); + if (nameNode === null || typeNode === null) return null; + const typeName = typeNode.text.replace(/^\*/, ''); + + return { + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, nameNode.text), + '@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text), + '@type-binding.type': syntheticCapture('@type-binding.type', typeNode, typeName), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts new file mode 100644 index 000000000..15d345736 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -0,0 +1,55 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateGoOwners, populateGoWorkspaceOwners } from './method-owners.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { loadGoModulePath } from '../../language-config.js'; +import { goProvider } from '../go.js'; +import { + goArityCompatibility, + goMergeBindings, + populateGoPackageSiblings, + resolveGoImportTarget, + mirrorGoNamespaceTypeBindings, +} from './index.js'; +import { detectGoInterfaceImplementations } from './interface-impls.js'; +import { populateGoRangeBindings } from './range-binding.js'; +import { expandGoWildcardNames } from './expand-wildcards.js'; + +export const goScopeResolver: ScopeResolver = { + language: SupportedLanguages.Go, + languageProvider: goProvider, + importEdgeReason: 'go-scope: import', + + loadResolutionConfig: (repoPath: string) => loadGoModulePath(repoPath), + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveGoImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandGoWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => goMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateGoOwners(parsed), + populateWorkspaceOwners: (parsedFiles, ctx) => populateGoWorkspaceOwners(parsedFiles, ctx), + + isSuperReceiver: () => false, + + fieldFallbackOnMethodLookup: false, + hoistTypeBindingsToModule: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, + + populateNamespaceSiblings: populateGoPackageSiblings, + mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings, + // Staged/V2: registered but not yet wired in run.ts — method-name-only + // matching produces false IMPLEMENTS edges; awaits signature-level comparison. + detectInterfaceImplementations: detectGoInterfaceImplementations, + populateRangeBindings: populateGoRangeBindings, +}; diff --git a/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts new file mode 100644 index 000000000..e2396f395 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts @@ -0,0 +1,38 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +export function goBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Keep self typeBindings in the method's Function scope (prevent + // auto-hoist to Module) so populateGoOwners can match Method defs + // to their receiver types by inspecting each Function scope. + if (decl['@type-binding.self'] !== undefined) { + return innermost.id; + } + return null; // default auto-hoist for other bindings +} + +export function goImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +export function goReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + for (const binding of functionScope.typeBindings.values()) { + if (binding.source === 'self') return binding; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/go/type-binding.ts b/gitnexus/src/core/ingestion/languages/go/type-binding.ts new file mode 100644 index 000000000..c0808a990 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/type-binding.ts @@ -0,0 +1,285 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + + for (const node of rootNode.descendantsOfType('short_var_declaration')) { + const right = node.childForFieldName('right'); + if (right === null) continue; + const lhs = node.childForFieldName('left'); + if (lhs === null) continue; + + // Multi-assignment: pair LHS identifiers positionally with RHS + // expressions. The tree-sitter query produces all LHS/RHS + // combinations; emit only the positions whose RHS carries an + // inferable type. + const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier'); + const rhsExprs = right.namedChildren; + if (lhsIds.length >= 2 && rhsExprs.length >= 2) { + for (let i = 0; i < Math.min(lhsIds.length, rhsExprs.length); i++) { + const lhsId = lhsIds[i]!; + const rhsExpr = rhsExprs[i]!; + const typeNode = extractTypeNode(rhsExpr); + if (typeNode === null) continue; + const typeName = extractSimpleTypeNameText(typeNode); + out.push({ + '@type-binding.multi-assign': syntheticCapture( + '@type-binding.multi-assign', + node, + lhsId.text, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', lhsId, lhsId.text), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + typeNode ?? rhsExpr, + typeName, + ), + }); + } + continue; // synthesized matches replace tree-sitter combinations + } + + // Walk the expression_list for call_expression function == "new" or "make" + for (let i = 0; i < right.namedChildCount; i++) { + const expr = right.namedChild(i); + if (expr?.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + const args = expr.childForFieldName('arguments'); + if (fn?.type === 'identifier' && fn.text === 'new' && args !== null) { + const typeArg = args.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + if (typeArg !== null) { + const typeName = extractSimpleTypeNameText(typeArg); + const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier'); + if (nameNodes.length > 0) { + out.push({ + '@type-binding.new': syntheticCapture('@type-binding.new', node, 'new'), + '@type-binding.name': syntheticCapture( + '@type-binding.name', + nameNodes[0], + nameNodes[0].text, + ), + '@type-binding.type': syntheticCapture('@type-binding.type', typeArg, typeName), + }); + } + } + } + if (fn?.type === 'identifier' && fn.text === 'make' && args !== null) { + const sliceOrMap = args.namedChildren.find((c) => + // V1: channel_type not handled — make(chan T) produces no typeBinding. + ['slice_type', 'map_type'].includes(c.type), + ); + if (sliceOrMap !== null) { + let typeName = ''; + if (sliceOrMap.type === 'slice_type') { + const elem = sliceOrMap.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + if (elem !== null) typeName = extractSimpleTypeNameText(elem); + } else if (sliceOrMap.type === 'map_type') { + const typeChildren = sliceOrMap.namedChildren.filter((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + const valueType = typeChildren[1] ?? typeChildren[0]; + if (valueType !== undefined) typeName = extractSimpleTypeNameText(valueType); + } + if (typeName !== '') { + const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier'); + if (nameNodes.length > 0) { + out.push({ + '@type-binding.make': syntheticCapture('@type-binding.make', node, 'make'), + '@type-binding.name': syntheticCapture( + '@type-binding.name', + nameNodes[0], + nameNodes[0].text, + ), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + sliceOrMap, + typeName, + ), + }); + } + } + } + } + } + } + } + + // Synthesize typeBindings for for-range loop variables and index + // expressions (sl[0], m["key"]) so member calls on them resolve. + synthesizeElementAccessBindings(rootNode, out); + + return out; +} + +function synthesizeElementAccessBindings(rootNode: SyntaxNode, out: CaptureMatch[]): void { + // Build a map of variable → element type from make/new/range. + const varElementType = new Map(); + + for (const node of rootNode.descendantsOfType('short_var_declaration')) { + const right = node.childForFieldName('right'); + const lhs = node.childForFieldName('left'); + if (right === null || lhs === null) continue; + const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier'); + if (lhsIds.length === 0) continue; + + for (const expr of right.namedChildren) { + let typeName: string | undefined; + if (expr.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + if (fn?.type === 'identifier' && (fn.text === 'make' || fn.text === 'new')) { + const args = expr.childForFieldName('arguments'); + const typeNode = args?.namedChildren.find((c) => + ['type_identifier', 'qualified_type', 'slice_type', 'map_type'].includes(c.type), + ); + if (typeNode) { + if (typeNode.type === 'slice_type') { + const elem = typeNode.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + if (elem) typeName = extractSimpleTypeNameText(elem); + } else if (typeNode.type === 'map_type') { + const tc = typeNode.namedChildren.filter((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + typeName = tc[1] + ? extractSimpleTypeNameText(tc[1]) + : tc[0] + ? extractSimpleTypeNameText(tc[0]) + : undefined; + } else { + typeName = extractSimpleTypeNameText(typeNode); + } + } + } + } + if (typeName !== undefined && lhsIds.length > 0) { + varElementType.set(lhsIds[0]!.text, typeName); + } + } + } + + // Handle for-range: for _, user := range GetUsers() / range slice / range map + for (const rangeClause of rootNode.descendantsOfType('range_clause')) { + const right = rangeClause.childForFieldName('right'); + if (right === null || right.namedChildren.length === 0) continue; + const left = rangeClause.childForFieldName('left'); + if (left === null) continue; + + // The right side IS the range expression (call_expression, identifier, etc.) + const rangeExpr = right; + + let elemType: string | undefined; + // Call expression: range GetUsers() + if (rangeExpr.type === 'call_expression') { + const fn = rangeExpr.childForFieldName('function'); + if (fn !== null) { + elemType = fn.text; + } + } + // Identifier: range userMap / range users + if (rangeExpr.type === 'identifier' || rangeExpr.type === 'selector_expression') { + const existing = varElementType.get(rangeExpr.text); + if (existing !== undefined) elemType = existing; + } + + if (elemType === undefined) continue; + + // Capture the loop variable (skip blank_identifier like _) + for (const child of left.namedChildren) { + if (child.type === 'identifier') { + // Create a typeBinding: loopVar → elemType + out.push({ + '@type-binding.range': syntheticCapture('@type-binding.range', rangeClause, child.text), + '@type-binding.name': syntheticCapture('@type-binding.name', child, child.text), + '@type-binding.type': syntheticCapture('@type-binding.type', rangeExpr, elemType), + }); + } + } + } + + // Handle index expressions: sl[0], m["key"] + for (const node of rootNode.descendantsOfType('call_expression')) { + const fn = node.childForFieldName('function'); + if (fn?.type !== 'selector_expression') continue; + const operand = fn.childForFieldName('operand'); + if (operand === null) continue; + + if (operand.type === 'index_expression') { + const base = operand.childForFieldName('operand'); + if (base?.type === 'identifier' && varElementType.has(base.text)) { + const elemType = varElementType.get(base.text)!; + out.push({ + '@type-binding.index': syntheticCapture('@type-binding.index', node, operand.text), + '@type-binding.name': syntheticCapture('@type-binding.name', operand, operand.text), + '@type-binding.type': syntheticCapture('@type-binding.type', operand, elemType), + }); + } + } + } +} + +function extractSimpleTypeNameText(node: SyntaxNode): string { + if (node.type === 'qualified_type') { + const parts = node.text.split('.'); + return parts[parts.length - 1] ?? node.text; + } + return node.text; +} + +/** Extract the type/signature node from a RHS expression. */ +function extractTypeNode(expr: SyntaxNode): SyntaxNode | null { + if (expr.type === 'composite_literal') { + return ( + expr.childForFieldName('type') ?? + expr.namedChildren.find((c) => ['type_identifier', 'qualified_type'].includes(c.type)) ?? + null + ); + } + if (expr.type === 'unary_expression') { + const operand = expr.childForFieldName('operand'); + return operand === null ? null : extractTypeNode(operand); + } + if (expr.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + if (fn?.type === 'identifier' && fn.text === 'new') { + const args = expr.childForFieldName('arguments'); + return ( + args?.namedChildren.find((c) => + ['type_identifier', 'qualified_type', 'pointer_type'].includes(c.type), + ) ?? null + ); + } + if (fn?.type === 'identifier' && fn.text === 'make') { + const args = expr.childForFieldName('arguments'); + const container = args?.namedChildren.find((c) => + ['slice_type', 'map_type'].includes(c.type), + ); + if (container?.type === 'slice_type') { + return ( + container.namedChildren.find((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ) ?? null + ); + } + if (container?.type === 'map_type') { + const typeChildren = container.namedChildren.filter((c) => + ['type_identifier', 'qualified_type'].includes(c.type), + ); + return typeChildren[1] ?? typeChildren[0] ?? null; + } + } + if (fn?.type === 'identifier') return fn; + if (fn?.type === 'selector_expression') { + return fn.childForFieldName('field') ?? fn; + } + } + if (expr.type === 'type_assertion_expression') { + return expr.childForFieldName('type'); + } + return null; +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 3eaeb4579..713b32c07 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -70,6 +70,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set, resolutionConfig?: unknown, - ): string | null; + ): string | readonly string[] | null; + + /** + * Enumerate names visible through a wildcard import after the target + * module scope has been linked. Languages that do not support + * wildcard-style imports leave this undefined. + */ + readonly expandsWildcardTo?: ( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], + ) => readonly string[]; /** * Optional one-shot loader for cross-file import-resolution config @@ -384,6 +395,18 @@ export interface ScopeResolver { */ populateOwners(parsed: ParsedFile): void; + /** + * Optional workspace-wide ownership reconciliation for languages whose + * member owner can be declared in a different file from the owner type. + * Runs after every file has had `populateOwners(parsed)` applied, but + * still before `reconcileOwnership`, so stamped ownerIds are copied into + * the semantic model registries. + */ + readonly populateWorkspaceOwners?: ( + parsedFiles: readonly ParsedFile[], + ctx: { readonly fileContents: ReadonlyMap }, + ) => void; + /** * Recognize a `super(...)`-style receiver text. Python returns * `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may @@ -441,6 +464,14 @@ export interface ScopeResolver { */ readonly collapseMemberCallsByCallerTarget?: boolean; + /** + * Allow free-call emission to fall back to a unique workspace-wide + * callable match when lexical/import bindings miss. Kept opt-in + * because this mirrors legacy resolver behavior for some languages + * but is too loose as a default for strict module systems. + */ + readonly allowGlobalFreeCallFallback?: boolean; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after @@ -485,4 +516,52 @@ export interface ScopeResolver { * level bindings. */ readonly hoistTypeBindingsToModule?: boolean; + + /** + * Optional: detect structural (duck-typing) interface implementations. + * Languages like Go use structural typing — a struct satisfies an + * interface if its method set is a superset, without an explicit + * `implements` keyword. Runs after finalize, before resolution passes. + * Returns: Map. + * Default: undefined (no structural interface detection). + */ + readonly detectInterfaceImplementations?: ( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + model: SemanticModel, + ) => Map; + + /** + * Optional: mirror typeBindings from namespace-import target modules + * into the importer's module scope. Languages like Go use namespace + * imports (`import "pkg"`) and need the target package's exported + * typeBindings visible in the importer's scope chain for cross-package + * return-type resolution (e.g. `x := pkg.NewUser(); x.Save()` needs + * `NewUser → User` mirrored from the target package). Runs after + * `populateNamespaceSiblings` and before `propagateImportedReturnTypes` + * so the SCC-ordered pass sees the mirrored bindings. + * Default: undefined (no namespace typeBinding mirroring). + */ + readonly mirrorNamespaceTypeBindings?: ( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + workspaceIndex: import('../../scope-resolution/workspace-index.js').WorkspaceResolutionIndex, + ) => void; + + /** + * Optional: bind for-range loop variables to their element/value types. + * Languages like Go need to resolve `for _, v := range m` where `m` is + * `map[K]V` — the variable `v` should bind to `V`. Runs after finalize, + * before resolution passes. Mutates scope typeBindings via the Map cast + * convention (see Invariant I8). + * Default: undefined (no range variable binding). + */ + readonly populateRangeBindings?: ( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, + ) => void; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 39f5a9a74..ad59f4f10 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -21,7 +21,6 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { generateId } from '../../../../lib/utils.js'; import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js'; - /** * Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the * source ("caller"). A Variable / Property can be the TARGET of an diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index e6c160467..248ee439f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -36,6 +36,7 @@ export function emitFreeCallFallback( handledSites: Set, model: SemanticModel, workspaceIndex: WorkspaceResolutionIndex, + options: { readonly allowGlobalFallback?: boolean } = {}, ): number { let emitted = 0; const seen = new Set(); @@ -67,6 +68,13 @@ export function emitFreeCallFallback( if (fnDef === undefined) { fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); } + // V1: pickUniqueGlobalCallable ignores import context — resolves to any + // globally-unique callable. False cross-package edges are possible when + // the caller does not import the target package. Same-package calls are + // caught by findCallableBindingInScope above before reaching here. + if (fnDef === undefined && options.allowGlobalFallback === true) { + fnDef = pickUniqueGlobalCallable(site.name, model, scopes); + } if (fnDef === undefined) continue; const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); if (callerGraphId === undefined) continue; @@ -95,6 +103,51 @@ export function emitFreeCallFallback( return emitted; } +function pickUniqueGlobalCallable( + name: string, + model: SemanticModel, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const scopeDefs: SymbolDefinition[] = []; + const scopeSeen = new Set(); + for (const def of scopes.defs.byId.values()) { + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; + if (simple !== name) continue; + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + const key = logicalCallableKey(def); + if (scopeSeen.has(key)) continue; + scopeSeen.add(key); + scopeDefs.push(def); + } + if (scopeDefs.length === 1) return scopeDefs[0]; + + const defs: SymbolDefinition[] = []; + const seen = new Set(); + const push = (pool: readonly SymbolDefinition[]): void => { + for (const def of pool) { + const key = logicalCallableKey(def); + if (seen.has(key)) continue; + seen.add(key); + defs.push(def); + } + }; + + push(model.symbols.lookupCallableByName(name)); + push(model.methods.lookupMethodByName(name)); + + return defs.length === 1 ? defs[0] : undefined; +} + +function logicalCallableKey(def: SymbolDefinition): string { + return [ + def.filePath, + def.qualifiedName ?? '', + def.type, + def.parameterCount ?? '', + def.parameterTypes?.join(',') ?? '', + ].join('\0'); +} + /** For a constructor call `new X(...)`, return the X class's explicit * Constructor def (by walking the class scope's ownedDefs) or the * Class def itself when no explicit Constructor exists. Matches diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 3ad9a28ae..cbdfc62aa 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -59,7 +59,7 @@ const RECHAIN_MAX_DEPTH = 8; * `followChainedRef` but operates on post-finalize Scope objects so * it can see imported return-types propagated by * `propagateImportedReturnTypes`. */ -function followChainPostFinalize( +export function followChainPostFinalize( start: TypeRef, fromScopeId: ScopeId, scopes: ScopeResolutionIndexes, diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts index a4d7e4cfb..ab6842771 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts @@ -24,6 +24,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { LinearizeStrategy } from '../contract/scope-resolver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; +import { isClassLike } from '../scope/walkers.js'; /** * Build an MRO map keyed by scope-resolution Class `DefId`. @@ -58,7 +59,7 @@ export function buildMro( const defIdByGraphId = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { - if (def.type !== 'Class') continue; + if (!isClassLike(def.type)) continue; const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 2c994c0ae..925151b6c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -46,6 +46,7 @@ import { findExportedDef, findOwnedMember, findReceiverTypeBinding, + isClassLike, } from '../scope/walkers.js'; import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; @@ -249,25 +250,30 @@ export function emitReceiverBoundCalls( } // ── Case 1: namespace receiver ─────────────────────────────── - const targetFile = namespaceTargets.get(receiverName); - if (targetFile !== undefined) { - const memberDef = findExportedDef(targetFile, memberName, index); - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', - seen, - 0.85, - collapse, - ); - if (ok) emitted++; - handledSites.add(siteKey); - continue; + const targetFiles = namespaceTargets.get(receiverName); + if (targetFiles !== undefined) { + let found = false; + for (const targetFile of targetFiles) { + const memberDef = findExportedDef(targetFile, memberName, index); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + seen, + 0.85, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + found = true; + break; + } } + if (found) continue; } // ── Case 2: class-name receiver ────────────────────────────── @@ -309,28 +315,33 @@ export function emitReceiverBoundCalls( if (typeRef !== undefined && typeRef.rawName.includes('.')) { const [nsName, ...classNameParts] = typeRef.rawName.split('.'); const className = classNameParts.join('.'); - const targetFile3 = namespaceTargets.get(nsName); - if (targetFile3 !== undefined && className.length > 0) { - const classDef3 = findExportedDef(targetFile3, className, index); - if (classDef3 !== undefined) { - const memberDef = findOwnedMember(classDef3.nodeId, memberName, model); - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); + const targetFiles3 = namespaceTargets.get(nsName); + if (targetFiles3 !== undefined && className.length > 0) { + let found3 = false; + for (const targetFile3 of targetFiles3) { + const classDef3 = findExportedDef(targetFile3, className, index); + if (classDef3 !== undefined) { + const memberDef = findOwnedMember(classDef3.nodeId, memberName, model); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + found3 = true; + break; } - continue; } } + if (found3) continue; } } @@ -394,10 +405,20 @@ export function emitReceiverBoundCalls( if (typeRef !== undefined && !typeRef.rawName.includes('.')) { let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); // `findClassBindingInScope(..., typeRef.rawName)` only works when - // rawName is itself a class symbol. Map for-of tuple bindings - // (`__MAP_TUPLE_i__:mapId`), callable aliases (`getUser` → User), - // and other compound-friendly shapes need the compound resolver - // keyed by the receiver identifier. + // rawName is itself a class symbol reachable through scope bindings. + // For languages with namespace-style imports (Go), imported types + // don't create bindings. Fall back to QualifiedNameIndex — single- + // match wins; ambiguous/missing falls through. + if (ownerDef === undefined) { + const qnameIds = scopes.qualifiedNames.get(typeRef.rawName); + if (qnameIds.length === 1) { + const qdef = scopes.defs.get(qnameIds[0]!); + if (qdef !== undefined && isClassLike(qdef.type)) ownerDef = qdef; + } + } + // Map for-of tuple bindings (`__MAP_TUPLE_i__:mapId`), callable + // aliases (`getUser` → User), and other compound-friendly shapes + // need the compound resolver keyed by the receiver identifier. if (ownerDef === undefined) { ownerDef = resolveCompoundReceiverClass( receiverName, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index 7b96f2136..3d9915fd7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -14,6 +14,7 @@ import type { ScopeResolver } from '../contract/scope-resolver.js'; import { pythonScopeResolver } from '../../languages/python/scope-resolver.js'; import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js'; import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js'; +import { goScopeResolver } from '../../languages/go/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -26,4 +27,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Python, pythonScopeResolver], [SupportedLanguages.CSharp, csharpScopeResolver], [SupportedLanguages.TypeScript, typescriptScopeResolver], + [SupportedLanguages.Go, goScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index feebf2405..b069f0dd1 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -90,6 +90,14 @@ export function runScopeResolution( const onWarn = input.onWarn ?? (() => {}); const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; const tStart = PROF ? process.hrtime.bigint() : 0n; + let fileContents: Map | undefined; + const getFileContents = (): Map => { + if (fileContents === undefined) { + fileContents = new Map(); + for (const f of files) fileContents.set(f.path, f.content); + } + return fileContents; + }; // ── Phase 1: extract each file → ParsedFile ──────────────────────────── const parsedFiles: ParsedFile[] = []; @@ -111,6 +119,7 @@ export function runScopeResolution( provider.populateOwners(parsed); parsedFiles.push(parsed); } + provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() }); // Reconcile scope-resolution's ownership view into the SemanticModel. // See `reconcile-ownership.ts` for the full rationale (Contract @@ -149,6 +158,8 @@ export function runScopeResolution( hooks: { resolveImportTarget: (targetRaw, fromFile) => provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + expandsWildcardTo: (targetModuleScope) => + provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => provider.mergeBindings(existing, incoming, scopeId), }, @@ -178,16 +189,21 @@ export function runScopeResolution( // The hook writes to `bindingAugmentations` only; finalized // `indexes.bindings` remains immutable post-finalize (I8). if (provider.populateNamespaceSiblings !== undefined) { - const fileContents = new Map(); - for (const f of files) fileContents.set(f.path, f.content); provider.populateNamespaceSiblings(parsedFiles, indexes, { - fileContents, + fileContents: getFileContents(), treeCache, }); } const tFinalize = PROF ? process.hrtime.bigint() : 0n; + // Cross-package namespace typeBinding mirroring. Runs before + // propagateImportedReturnTypes so the SCC-ordered pass sees the + // mirrored bindings. + if (provider.mirrorNamespaceTypeBindings !== undefined) { + provider.mirrorNamespaceTypeBindings(parsedFiles, indexes, workspaceIndex); + } + // Cross-file return-type propagation (Contract Invariant I3 timing: // after finalize, before resolve). Split-timed separately so the // SCC-ordered pass's cost is observable (PR #1050 made this O(files) @@ -196,6 +212,13 @@ export function runScopeResolution( if (provider.propagatesReturnTypesAcrossImports !== false) { propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex); } + + if (provider.populateRangeBindings !== undefined) { + provider.populateRangeBindings(parsedFiles, indexes, { + fileContents: getFileContents(), + treeCache, + }); + } const tPropagate = PROF ? process.hrtime.bigint() : 0n; // Opt-in I8 invariant guard. Runs once after all post-finalize hooks @@ -237,6 +260,7 @@ export function runScopeResolution( handledSites, readonlyModel, workspaceIndex, + { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true }, ); const { emitted, skipped } = emitReferencesViaLookup( graph, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index 4af63bd75..6ee0e2a16 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -38,8 +38,8 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe export function collectNamespaceTargets( parsed: ParsedFile, scopes: ScopeResolutionIndexes, -): Map { - const out = new Map(); +): Map { + const out = new Map(); const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; @@ -51,7 +51,12 @@ export function collectNamespaceTargets( for (const edge of moduleEdges) { if (edge.targetFile === null) continue; if (!namespaceLocals.has(edge.localName)) continue; - out.set(edge.localName, edge.targetFile); + let targets = out.get(edge.localName); + if (targets === undefined) { + targets = []; + out.set(edge.localName, targets); + } + if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); } return out; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 27298328c..3e347cd4e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -187,6 +187,27 @@ export function findClassBindingInScope( currentId = scope.parent; } + // Fallback for languages (Go) where namespace-style imports don't + // create scope bindings: resolve via QualifiedNameIndex. Only fires + // when the scope-chain walk found nothing; single-match wins. + const qnames = scopes.qualifiedNames.get(receiverName); + if (qnames.length === 1) { + const def = scopes.defs.get(qnames[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } + // Second fallback: dotted names like "models.User" — try the simple + // name (tail after last dot) for languages where defs are indexed by + // simple name (Go). Only when the dotted lookup fails. + if (receiverName.includes('.')) { + const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1); + if (simple.length > 0 && simple !== receiverName) { + const simpleIds = scopes.qualifiedNames.get(simple); + if (simpleIds.length === 1) { + const def = scopes.defs.get(simpleIds[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } + } + } return undefined; } diff --git a/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod new file mode 100644 index 000000000..02d98452c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod @@ -0,0 +1,3 @@ +module example.com/aliasimport + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go new file mode 100644 index 000000000..c9920227e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go @@ -0,0 +1,3 @@ +package util + +func Log() {} diff --git a/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go new file mode 100644 index 000000000..d3a46469d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go @@ -0,0 +1,7 @@ +package main + +import util "example.com/aliasimport/internal/util" + +func main() { + util.Log() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod new file mode 100644 index 000000000..70b6fc53d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod @@ -0,0 +1,3 @@ +module example.com/samefactory + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go new file mode 100644 index 000000000..d3878c76e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go @@ -0,0 +1,10 @@ +package main + +func NewUser() *User { + return &User{} +} + +func processUser() { + user := NewUser() + user.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go new file mode 100644 index 000000000..e760c880d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go @@ -0,0 +1,7 @@ +package main + +type Repo struct{} + +func (r *Repo) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go new file mode 100644 index 000000000..10a724c78 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go @@ -0,0 +1,7 @@ +package main + +type User struct{} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod new file mode 100644 index 000000000..d7c722bd7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod @@ -0,0 +1,3 @@ +module example.com/splitowner + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go new file mode 100644 index 000000000..c96ec3516 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go @@ -0,0 +1,6 @@ +package main + +func process() { + user := User{} + user.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go new file mode 100644 index 000000000..e760c880d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go @@ -0,0 +1,7 @@ +package main + +type Repo struct{} + +func (r *Repo) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go new file mode 100644 index 000000000..c6f550ef8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go @@ -0,0 +1,5 @@ +package main + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go new file mode 100644 index 000000000..150e04c86 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go @@ -0,0 +1,3 @@ +package main + +type User struct{} diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index f9a57d660..2a9b637ca 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1,11 +1,12 @@ /** * Go: package imports + cross-package calls + ambiguous struct disambiguation */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, CROSS_FILE_FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -14,6 +15,8 @@ import { type PipelineResult, } from './helpers.js'; +const it = createResolverParityIt('go'); + // --------------------------------------------------------------------------- // Heritage: package imports + cross-package calls (exercises PackageMap) // --------------------------------------------------------------------------- @@ -610,6 +613,30 @@ describe('Go return type inference via explicit function return type', () => { }); }); +describe('Go same-package factory return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-same-package-factory'), () => {}); + }, 60000); + + it('resolves user.Save() through same-package NewUser() return type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find( + (c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'user.go', + ); + expect(userSave).toBeDefined(); + }); + + it('does not resolve user.Save() to Repo.Save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find( + (c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'repo.go', + ); + expect(repoSave).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // Go multi-return factory inference: user, err := NewUser("alice"); user.Save() // --------------------------------------------------------------------------- @@ -1261,6 +1288,47 @@ describe('Go cross-file binding propagation', () => { }); }); +describe('Go aliased package selector resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-aliased-package-import'), () => {}); + }, 60000); + + it('resolves util.Log() through an aliased package import', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find( + (c) => + c.target === 'Log' && c.source === 'main' && c.targetFilePath === 'internal/util/log.go', + ); + expect(logCall).toBeDefined(); + }); +}); + +describe('Go method owner resolution across package files', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-split-method-owner'), () => {}); + }, 60000); + + it('resolves user.Save() to the method whose receiver type is declared in another package file', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find( + (c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'save.go', + ); + expect(userSave).toBeDefined(); + }); + + it('does not resolve user.Save() to Repo.Save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find( + (c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'repo.go', + ); + expect(repoSave).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // Go cmd/ helper files should NOT get entry-point multiplier (P0-1 fix) // Only main.go files should get the 3.0 entry-point boost, not arbitrary diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 427fd7e5f..5368c1715 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -12,6 +12,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Models/User.cs through the scope-resolution path', ]), + go: new Set([ + // The legacy DAG path does not resolve method calls when the method is + // defined in a different file from the receiver type (go-split-method-owner + // fixture). This requires scope-based cross-file package-sibling resolution + // which is only available in the registry-primary path. + 'resolves user.Save() to the method whose receiver type is declared in another package file', + ]), python: new Set([ // Suffix-fallback lex tiebreak depends on the registry-primary // resolver's deterministic sort. The legacy resolver returns the diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 848672a7a..5f688c732 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -108,10 +108,9 @@ describe('isRegistryPrimary', () => { it('isolates flags per-language (one on does not affect others)', () => { process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - // Java and Go are not in MIGRATED_LANGUAGES — default false stays + // Java is not in MIGRATED_LANGUAGES — default false stays // false regardless of Python's flag. expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); - expect(isRegistryPrimary(SupportedLanguages.Go)).toBe(false); }); it('respects a mid-process env-var mutation (no stale cache)', () => { @@ -149,17 +148,18 @@ describe('primaryLanguages', () => { it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => { // Migrated languages are default-on; each must be opted out here when - // testing explicit env overrides. Go (unmigrated) opts in; Java stays off. + // testing explicit env overrides. Java (unmigrated) opts in; Go stays off. process.env['REGISTRY_PRIMARY_PYTHON'] = 'false'; process.env['REGISTRY_PRIMARY_CSHARP'] = 'false'; process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false'; - process.env['REGISTRY_PRIMARY_GO'] = '1'; + process.env['REGISTRY_PRIMARY_GO'] = 'false'; + process.env['REGISTRY_PRIMARY_JAVA'] = '1'; const enabled = primaryLanguages(); expect(enabled.has(SupportedLanguages.Python)).toBe(false); expect(enabled.has(SupportedLanguages.CSharp)).toBe(false); - expect(enabled.has(SupportedLanguages.Go)).toBe(true); - expect(enabled.has(SupportedLanguages.Java)).toBe(false); - // Only Go is on: migrated defaults overridden off, Go explicitly on. + expect(enabled.has(SupportedLanguages.Go)).toBe(false); + expect(enabled.has(SupportedLanguages.Java)).toBe(true); + // Only Java is on: migrated defaults overridden off, Java explicitly on. expect(enabled.size).toBe(1); }); diff --git a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts new file mode 100644 index 000000000..b921bddc4 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js'; + +const tagNames = (matches: readonly Record[]) => + matches.flatMap((m) => Object.keys(m)); + +describe('Go scope captures — smoke', () => { + it('emits grouped imports once per import spec', () => { + const src = ` +package main + +import ( + "fmt" + "os" +) +`; + const matches = emitGoScopeCaptures(src, 'main.go'); + const imports = matches + .filter((m) => m['@import.source'] !== undefined) + .map((m) => m['@import.source']!.text); + + expect(imports).toEqual(['fmt', 'os']); + }); + + it('emits module, struct, interface, function, method, import, call, read, write captures', () => { + const src = ` +package main + +import ( + "example.com/app/internal/models" + util "example.com/app/internal/util" +) + +type User struct { Name string } +type Saver interface { Save() } + +func NewUser(name string) *User { return &User{Name: name} } + +func (u *User) Save(prefix string) { util.Log(prefix); models.Touch() } + +func main() { + u := NewUser("alice") + u.Save("hello") + fmt.Println(u.Name) + u.Name = "bob" +} +`; + const matches = emitGoScopeCaptures(src, 'cmd/main.go'); + const tags = tagNames(matches); + + expect(tags).toContain('@scope.module'); + expect(tags).toContain('@scope.class'); + expect(tags).toContain('@scope.function'); + expect(tags).toContain('@declaration.struct'); + expect(tags).toContain('@declaration.interface'); + expect(tags).toContain('@declaration.function'); + expect(tags).toContain('@declaration.method'); + expect(tags).toContain('@import.statement'); + expect(tags).toContain('@reference.call.free'); + expect(tags).toContain('@reference.call.member'); + expect(tags).toContain('@reference.call.constructor'); + expect(tags).toContain('@reference.read'); + expect(tags).toContain('@reference.write'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts new file mode 100644 index 000000000..42424a3bd --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import type { BindingRef, Callsite, SymbolDefinition, Scope } from 'gitnexus-shared'; +import { + goArityCompatibility, + goMergeBindings, + goReceiverBinding, +} from '../../../../src/core/ingestion/languages/go/index.js'; + +describe('Go arity compatibility', () => { + const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:1', + filePath: 'a.go', + type: 'Function', + qualifiedName: 'F', + ...overrides, + }); + + it('returns unknown when no param count info', () => { + const def = makeDef(); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 1, + }; + expect(goArityCompatibility(def, callsite)).toBe('unknown'); + }); + + it('exact match is compatible', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 2, + }; + expect(goArityCompatibility(def, callsite)).toBe('compatible'); + }); + + it('too few args is incompatible', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 1, + }; + expect(goArityCompatibility(def, callsite)).toBe('incompatible'); + }); + + it('variadic accepts extra args', () => { + const def = makeDef({ + parameterCount: 2, + requiredParameterCount: 1, + parameterTypes: ['string', '...string'], + }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 5, + }; + expect(goArityCompatibility(def, callsite)).toBe('compatible'); + }); + + it('non-variadic rejects extra args', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + const callsite: Callsite = { + name: 'F', + inScope: 's', + atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + kind: 'call', + arity: 3, + }; + expect(goArityCompatibility(def, callsite)).toBe('incompatible'); + }); +}); + +describe('Go merge bindings', () => { + it('local wins over import', () => { + const local: BindingRef = { + origin: 'local', + def: { nodeId: 'def:local', filePath: 'main.go', type: 'Function', qualifiedName: 'Save' }, + }; + const imported: BindingRef = { + origin: 'import', + def: { nodeId: 'def:import', filePath: 'util.go', type: 'Function', qualifiedName: 'Save' }, + }; + const merged = goMergeBindings([imported], [local], 'scope:1'); + expect(merged[0].def.nodeId).toBe('def:local'); + }); + + it('deduplicates by DefId', () => { + const a: BindingRef = { + origin: 'local', + def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' }, + }; + const b: BindingRef = { + origin: 'local', + def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' }, + }; + expect(goMergeBindings([], [a, b], 'scope:1').length).toBe(1); + }); +}); + +describe('Go receiver binding', () => { + it('reads self type binding from function scope', () => { + const scope = { + kind: 'Function', + typeBindings: new Map([ + ['u', { rawName: 'User', declaredAtScope: 'scope:1', source: 'self' }], + ]), + } as unknown as Scope; + expect(goReceiverBinding(scope)?.rawName).toBe('User'); + }); + + it('returns null for non-Function scope', () => { + const scope = { kind: 'Module', typeBindings: new Map() } as unknown as Scope; + expect(goReceiverBinding(scope)).toBeNull(); + }); + + it('returns null when no self binding', () => { + const scope = { kind: 'Function', typeBindings: new Map() } as unknown as Scope; + expect(goReceiverBinding(scope)).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts new file mode 100644 index 000000000..1e1e715b3 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + splitGoImportStatement, + interpretGoImport, + resolveGoImportTarget, +} from '../../../../src/core/ingestion/languages/go/index.js'; +import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +function parseThenSplit(src: string): CaptureMatch[] { + const tree = getGoParser().parse(src); + const out: CaptureMatch[] = []; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'import_declaration') out.push(...splitGoImportStatement(child as any)); + } + return out; +} + +function capt(name: string, text: string) { + return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } }; +} + +describe('Go import decomposition', () => { + it('decomposes single default import', () => { + const matches = parseThenSplit('import "fmt"'); + expect(matches.length).toBe(1); + expect(matches[0]['@import.source']?.text).toBe('fmt'); + expect(matches[0]['@import.kind']?.text).toBe('namespace'); + expect(matches[0]['@import.name']?.text).toBe('fmt'); + }); + + it('decomposes grouped imports', () => { + const src = `import ( + "fmt" + "os" +)`; + const matches = parseThenSplit(src); + expect(matches.length).toBe(2); + }); + + it('decomposes aliased import', () => { + const matches = parseThenSplit('import util "example.com/pkg/util"'); + expect(matches.length).toBe(1); + expect(matches[0]['@import.kind']?.text).toBe('alias'); + expect(matches[0]['@import.name']?.text).toBe('util'); + expect(matches[0]['@import.source']?.text).toBe('example.com/pkg/util'); + }); + + it('filters blank imports', () => { + const matches = parseThenSplit('import _ "example.com/sideeffect"'); + expect(matches.length).toBe(0); + }); + + it('handles dot imports', () => { + const matches = parseThenSplit('import . "example.com/dsl"'); + expect(matches.length).toBe(1); + expect(matches[0]['@import.kind']?.text).toBe('dot'); + }); +}); + +describe('Go import interpretation', () => { + it('interprets namespace import', () => { + const result = interpretGoImport({ + '@import.kind': capt('@import.kind', 'namespace'), + '@import.name': capt('@import.name', 'models'), + '@import.source': capt('@import.source', 'example.com/app/models'), + }); + expect(result).toEqual({ + kind: 'namespace', + localName: 'models', + importedName: 'models', + targetRaw: 'example.com/app/models', + }); + }); + + it('interprets alias import', () => { + const result = interpretGoImport({ + '@import.kind': capt('@import.kind', 'alias'), + '@import.name': capt('@import.name', 'util'), + '@import.alias': capt('@import.alias', 'util'), + '@import.source': capt('@import.source', 'example.com/pkg/util'), + }); + expect(result).toEqual({ + kind: 'namespace', + localName: 'util', + importedName: 'util', + targetRaw: 'example.com/pkg/util', + }); + }); + + it('interprets dot import as wildcard', () => { + const result = interpretGoImport({ + '@import.kind': capt('@import.kind', 'dot'), + '@import.name': capt('@import.name', 'dsl'), + '@import.source': capt('@import.source', 'example.com/dsl'), + }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'example.com/dsl' }); + }); +}); + +describe('Go import target resolution', () => { + it('resolves module root imports to root package files', () => { + const result = resolveGoImportTarget( + 'example.com/lib', + 'cmd/app/main.go', + new Set(['root.go', 'extra.go', 'internal/model/model.go', 'root_test.go']), + { modulePath: 'example.com/lib' }, + ); + + expect(result).toEqual(['extra.go', 'root.go']); + }); + + it('resolves sub-package imports under module root', () => { + const result = resolveGoImportTarget( + 'example.com/lib/internal/models', + 'cmd/app/main.go', + new Set(['internal/models/user.go', 'internal/models/repo.go', 'root.go']), + { modulePath: 'example.com/lib' }, + ); + + expect(Array.isArray(result)).toBe(true); + expect((result as string[]).sort()).toEqual([ + 'internal/models/repo.go', + 'internal/models/user.go', + ]); + }); + + it('rejects single-segment GOPATH suffix that collides with a local dir', () => { + // "github.com/other/team/pkg" suffix-stripped would eventually + // reach "pkg" which matches the local pkg/ dir — but we require + // ≥2 segments in the GOPATH fallback, so it must not resolve. + const result = resolveGoImportTarget( + 'github.com/other/team/pkg', + 'main.go', + new Set(['pkg/util.go', 'main.go']), + ); + + expect(result).toBeNull(); + }); + + it('resolves multi-segment GOPATH suffix that matches local dir', () => { + // "github.com/other/team/pkg" where "team/pkg/" exists locally + // — the 2-segment suffix "team/pkg" should still resolve. + const result = resolveGoImportTarget( + 'github.com/other/team/pkg', + 'main.go', + new Set(['team/pkg/util.go', 'main.go']), + ); + + expect(Array.isArray(result)).toBe(true); + expect(result as string[]).toEqual(['team/pkg/util.go']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts b/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts new file mode 100644 index 000000000..8a4201745 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import { populateGoPackageSiblings } from '../../../../src/core/ingestion/languages/go/index.js'; + +describe('Go package siblings', () => { + it('augments bindings only for files in the same package directory', () => { + const fooDef = def('foo', 'cmd/foo/a.go', 'OnlyFoo'); + const fooHelperDef = def('foo-helper', 'cmd/foo/b.go', 'OnlyFooHelper'); + const barDef = def('bar', 'cmd/bar/a.go', 'OnlyBar'); + + const parsedFiles: ParsedFile[] = [ + parsed('cmd/foo/a.go', 'module:foo-a', fooDef), + parsed('cmd/foo/b.go', 'module:foo-b', fooHelperDef), + parsed('cmd/bar/a.go', 'module:bar-a', barDef), + ]; + const indexes = { + moduleScopes: { + byFilePath: new Map([ + ['cmd/foo/a.go', 'module:foo-a'], + ['cmd/foo/b.go', 'module:foo-b'], + ['cmd/bar/a.go', 'module:bar-a'], + ]), + }, + imports: new Map(), + bindings: new Map(), + bindingAugmentations: new Map(), + } as unknown as ScopeResolutionIndexes; + const fileContents = new Map([ + ['cmd/foo/a.go', 'package main\n'], + ['cmd/foo/b.go', 'package main\n'], + ['cmd/bar/a.go', 'package main\n'], + ]); + + populateGoPackageSiblings(parsedFiles, indexes, { fileContents }); + + const augmentations = indexes.bindingAugmentations; + expect(augmentations.get('module:foo-a')?.get('OnlyFooHelper')?.[0]?.def.nodeId).toBe( + 'foo-helper', + ); + expect(augmentations.get('module:foo-a')?.get('OnlyBar')).toBeUndefined(); + expect(augmentations.get('module:bar-a')?.get('OnlyFoo')).toBeUndefined(); + }); +}); + +function def(nodeId: string, filePath: string, name: string): SymbolDefinition { + return { nodeId, filePath, type: 'Function', qualifiedName: name }; +} + +function parsed(filePath: string, moduleScope: string, localDef: SymbolDefinition): ParsedFile { + return { + filePath, + moduleScope, + scopes: [], + parsedImports: [], + localDefs: [localDef], + referenceSites: [], + }; +} diff --git a/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts new file mode 100644 index 000000000..9ea213128 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + synthesizeGoReceiverBinding, + synthesizeGoTypeBindings, + emitGoScopeCaptures, + interpretGoTypeBinding, + normalizeGoTypeName, +} from '../../../../src/core/ingestion/languages/go/index.js'; +import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js'; + +describe('Go receiver binding', () => { + it('synthesizes receiver type binding for method', () => { + const src = 'package main\ntype User struct{}\nfunc (u *User) Save() {}'; + const tree = getGoParser().parse(src); + const methodNode = tree.rootNode.descendantsOfType('method_declaration')[0]; + const result = synthesizeGoReceiverBinding(methodNode as any)!; + expect(result['@type-binding.self']).toBeDefined(); + expect(result['@type-binding.name']!.text).toBe('u'); + expect(result['@type-binding.type']!.text).toBe('User'); + }); + + it('returns null for free function', () => { + const src = 'package main\nfunc Save() {}'; + const tree = getGoParser().parse(src); + const fnNode = tree.rootNode.descendantsOfType('function_declaration')[0]; + expect(synthesizeGoReceiverBinding(fnNode as any)).toBeNull(); + }); +}); + +describe('Go type binding synthesis — 7 patterns', () => { + it('synthesizes new() type binding', () => { + const src = 'package main\nfunc main() {\n user := new(User)\n}'; + const tree = getGoParser().parse(src); + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + expect(matches.length).toBeGreaterThanOrEqual(1); + const newMatch = matches.find((m) => m['@type-binding.new']); + expect(newMatch?.['@type-binding.name']?.text).toBe('user'); + expect(newMatch?.['@type-binding.type']?.text).toBe('User'); + const parsed = interpretGoTypeBinding(newMatch!); + expect(parsed?.rawTypeName).toBe('User'); + }); + + it('synthesizes make([]T) type binding', () => { + const src = 'package main\nfunc main() {\n sl := make([]User, 0)\n}'; + const tree = getGoParser().parse(src); + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + const makeMatch = matches.find((m) => m['@type-binding.make']); + expect(makeMatch?.['@type-binding.name']?.text).toBe('sl'); + expect(makeMatch?.['@type-binding.type']?.text).toBe('User'); + }); + + it('synthesizes make(map[K]V) type binding', () => { + const src = 'package main\nfunc main() {\n m := make(map[string]User)\n}'; + const tree = getGoParser().parse(src); + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + const makeMatch = matches.find((m) => m['@type-binding.make']); + expect(makeMatch?.['@type-binding.name']?.text).toBe('m'); + expect(makeMatch?.['@type-binding.type']?.text).toBe('User'); + }); + + it('supplements qualified type constructor: pkg.Foo{}', () => { + const src = 'package main\nfunc main() {\n u := models.User{}\n}'; + const matches = emitGoScopeCaptures(src, 'main.go'); + const qMatch = matches.find( + (m) => m['@type-binding.constructor'] && m['@type-binding.type']?.text === 'models.User', + ); + expect(qMatch).toBeDefined(); + }); + + it('keeps multi-assignment constructor bindings aligned with RHS positions', () => { + const src = 'package main\nfunc main() {\n a, b := 42, X{}\n}'; + const bindings = emitGoScopeCaptures(src, 'main.go') + .filter((m) => m['@type-binding.name'] !== undefined) + .map((m) => ({ + name: m['@type-binding.name']!.text, + type: m['@type-binding.type']!.text, + })); + + expect(bindings).toContainEqual({ name: 'b', type: 'X' }); + expect(bindings).not.toContainEqual({ name: 'a', type: 'X' }); + }); + + it('interprets assertion type binding', () => { + const result = interpretGoTypeBinding({ + '@type-binding.assertion': { + name: '@type-binding.assertion', + text: 's.(User)', + range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + }, + '@type-binding.name': { + name: '@type-binding.name', + text: 'user', + range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, + }, + '@type-binding.type': { + name: '@type-binding.type', + text: 'User', + range: { startLine: 1, startCol: 10, endLine: 1, endCol: 14 }, + }, + }); + expect(result?.rawTypeName).toBe('User'); + expect(result?.source).toBe('annotation'); + }); + + it('normalizes pointer, slice, map, qualified, generic type names', () => { + expect(normalizeGoTypeName('*User')).toBe('User'); + expect(normalizeGoTypeName('[]string')).toBe('string'); + expect(normalizeGoTypeName('map[string]int')).toBe('int'); + expect(normalizeGoTypeName('chan int')).toBe('int'); + expect(normalizeGoTypeName('func() error')).toBe('error'); + expect(normalizeGoTypeName('models.User')).toBe('User'); + expect(normalizeGoTypeName('List[User]')).toBe('List'); + }); +}); From 95814847bdfd8447e9f436a4312e36d1915abf2b Mon Sep 17 00:00:00 2001 From: "@aaronjmars" <61592645+aaronjmars@users.noreply.github.com> Date: Mon, 4 May 2026 03:08:46 -0400 Subject: [PATCH 02/12] fix(security): block IPv4-compatible IPv6 and NAT64 SSRF bypasses in validateGitUrl (#1148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): block IPv4-compatible IPv6 and NAT64 SSRF bypasses Vulnerability: SSRF via IPv6 forms that embed IPv4 addresses Severity: high Location: gitnexus/src/server/git-clone.ts:assertNotPrivateIPv6 validateGitUrl() blocks ::ffff:x.x.x.x (IPv4-mapped) but two related forms still slipped through — both routable to the embedded IPv4 on common stacks: 1. IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated): http://[::127.0.0.1]/ — Node's URL parser collapses this to "::7f00:1" with no ::ffff: marker, so the existing check missed it. 2. NAT64 well-known prefix (RFC 6052: 64:ff9b::/96, plus RFC 8215's 64:ff9b:1::/48 local prefix): a host with NAT64 enabled translates 64:ff9b::7f00:1 to 127.0.0.1, reaching loopback. Impact: an attacker who can submit a clone URL to /api/analyze (any caller in the CORS-allowlisted origin set — localhost, RFC 1918 LAN, or gitnexus.vercel.app) could direct git clone at loopback or cloud metadata addresses (169.254.169.254 → ::a9fe:a9fe, 64:ff9b::a9fe:a9fe). Fix: extend assertNotPrivateIPv6 to reject any address compressed to ::xxxx[:yyyy] and any address starting with the NAT64 prefix 64:ff9b:. Tests added for both forms plus the cloud-metadata variants. * fix(security): block 6to4 SSRF bypass and add expanded-form regression tests Address review findings on PR #1148: - Block 6to4 (2002::/16, RFC 3056). The prefix encodes an IPv4 address in bits 17-48, so 2002:7f00:0001::* routes to 127.0.0.1 on 6to4-capable stacks. RFC 7526 deprecated the protocol and the public relay anycast has been retired, so broad-blocking has near-zero false-positive cost. - Expand the NAT64 comment to justify the broader-than-CIDR check: the whole 64:ff9b::/32 block is IANA-reserved for IPv4-IPv6 translation, so a future narrower CIDR refactor would silently re-open the bypass for 64:ff9b:1::/48 or any new translation range. - Add tests for expanded / zero-padded IPv4-compatible IPv6 forms ([0:0:0:0:0:0:7f00:1], fully zero-padded, mixed [0:...:127.0.0.1]). These pin the assumption that the WHATWG URL parser collapses these inputs to ::xxxx[:yyyy]; without them, a future Node anomaly would silently regress the bypass. - Add public IPv6 positive tests (Cloudflare 2606:4700::, Google 2001:4860::). Regression guard against over-blocking. - Add NAT64 + RFC1918 embedded-IP tests (10/8, 172.16/12, 192.168/16) to document SSRF coverage explicitly rather than relying on the prefix check. Co-Authored-By: Claude Opus 4.7 (1M context) * style: apply prettier formatting to git-clone.test.ts --------- Co-authored-by: aeonframework Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Gergo Magyar --- gitnexus/src/server/git-clone.ts | 33 ++++++++++++++ gitnexus/test/unit/git-clone.test.ts | 67 +++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 0f7bc2653..56c797d8a 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -139,6 +139,39 @@ function assertNotPrivateIPv6(ip: string): void { if (lower.includes(':ffff:')) { throw new Error('Cloning from private/internal addresses is not allowed'); } + + // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z). + // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4 + // is hidden in the last 32 bits without the ::ffff: marker, so the check + // above misses it. The form is still routable to the embedded IPv4 on most + // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked. + if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local + // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private + // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via + // e.g. 64:ff9b::7f00:1 → 127.0.0.1. + // The check intentionally covers the full 64:ff9b::/32 block (broader than + // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so + // blocking the whole prefix is defensively sound and prevents a narrower + // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any + // future translation assignment. + if (lower.startsWith('64:ff9b:')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } + + // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so + // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The + // protocol was deprecated by RFC 7526 and the public relay anycast + // (192.88.99.1) has been retired, so broad-blocking the prefix has near- + // zero false-positive cost while closing the IPv4-embedded bypass. + // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is + // impractical and is out of scope here. + if (lower.startsWith('2002:')) { + throw new Error('Cloning from private/internal addresses is not allowed'); + } } function assertNotPrivateIPv4(ip: string): void { diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 832f95641..6b8e74556 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -96,8 +96,73 @@ describe('git-clone', () => { ); }); - it('does not block valid public IPs', () => { + it('blocks IPv4-compatible IPv6 (RFC 4291 deprecated, ::w.x.y.z)', () => { + // Node's URL parser collapses ::127.0.0.1 to ::7f00:1 — no ::ffff: marker, + // but still routable to 127.0.0.1 on most stacks. + expect(() => validateGitUrl('http://[::127.0.0.1]/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://[::7f00:1]/repo.git')).toThrow('private/internal'); + // 169.254.169.254 (cloud metadata) embedded as IPv4-compatible + expect(() => validateGitUrl('http://[::a9fe:a9fe]/repo.git')).toThrow('private/internal'); + }); + + it('blocks IPv4-compatible IPv6 in expanded / zero-padded forms', () => { + // The compressed-form check above relies on the WHATWG URL parser + // normalising fully-expanded inputs to ::xxxx[:yyyy]. These cases pin + // that assumption: if a future Node release stops collapsing them, a + // bypass would silently re-open without these tests catching it. + expect(() => validateGitUrl('http://[0:0:0:0:0:0:7f00:1]/repo.git')).toThrow( + 'private/internal', + ); + expect(() => + validateGitUrl('http://[0000:0000:0000:0000:0000:0000:7f00:0001]/repo.git'), + ).toThrow('private/internal'); + // Mixed notation: trailing IPv4 quad in an otherwise expanded address. + expect(() => validateGitUrl('http://[0:0:0:0:0:0:127.0.0.1]/repo.git')).toThrow( + 'private/internal', + ); + }); + + it('blocks NAT64 well-known prefix (64:ff9b::/96)', () => { + // 64:ff9b::7f00:1 → 127.0.0.1 via NAT64 translation + expect(() => validateGitUrl('http://[64:ff9b::7f00:1]/repo.git')).toThrow('private/internal'); + expect(() => validateGitUrl('http://[64:ff9b::a9fe:a9fe]/repo.git')).toThrow( + 'private/internal', + ); + // RFC 8215 local NAT64 prefix + expect(() => validateGitUrl('http://[64:ff9b:1::1]/repo.git')).toThrow('private/internal'); + }); + + it('blocks NAT64 with embedded RFC1918 addresses', () => { + // The startsWith('64:ff9b:') check covers any embedded IPv4. These + // explicit RFC1918 cases document SSRF coverage for the full private + // IPv4 surface — not just loopback and cloud metadata. + expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1 + expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1 + expect(() => validateGitUrl('http://[64:ff9b::c0a8:101]/repo.git')).toThrow( + 'private/internal', + ); // 192.168.1.1 + }); + + it('blocks 6to4 prefix (2002::/16, RFC 3056)', () => { + // 6to4 encodes an IPv4 address in bits 17-48, so 2002:WWXX:YYZZ::* + // routes to W.X.Y.Z on 6to4-capable stacks. The protocol is deprecated + // (RFC 7526), so the entire 2002::/16 block is defensively rejected. + expect(() => validateGitUrl('http://[2002:7f00:1::1]/repo.git')).toThrow('private/internal'); // 127.0.0.1 + expect(() => validateGitUrl('http://[2002:a9fe:a9fe::1]/repo.git')).toThrow( + 'private/internal', + ); // 169.254.169.254 + expect(() => validateGitUrl('http://[2002:c0a8:101::1]/repo.git')).toThrow( + 'private/internal', + ); // 192.168.1.1 + }); + + it('does not block valid public IPs (IPv4 and IPv6)', () => { expect(() => validateGitUrl('https://140.82.121.4/repo.git')).not.toThrow(); + // Regression guard against over-blocking legitimate public IPv6. + // Cloudflare DNS (2606:4700::/32) and Google DNS (2001:4860::/32) — + // chosen because their prefixes don't collide with any block above. + expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow(); + expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow(); }); it('blocks CGN range (100.64.0.0/10)', () => { From 342721f06d649a47e04c76cac288dd6fc8c302ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 4 May 2026 08:21:53 +0100 Subject: [PATCH 03/12] ci(security): add automated security and vulnerability scans (#1297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(security): add CodeQL SAST workflow for JS/TS and Python CodeQL analyzes both languages on PR, main push, and weekly schedule. Findings upload to the Security tab as SARIF. Advisory only on introduction; promote to required check after baseline triage. Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U1) * ci(security): add Dependency Review PR gate Blocks PRs introducing high+ severity dependency vulnerabilities. Posts inline summary comment on failure. Required-check candidate after one week of clean runs. Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U2) * ci(security): add Gitleaks secret scanning PR runs scan the diff; main pushes scan full history. Defense-in-depth on top of GitHub native push protection (documented as a recommended Settings toggle in SECURITY.md). Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U3) * ci(security): add OpenSSF Scorecard workflow Weekly + on main push. SARIF uploads to Security tab; public badge URL resolves after first scheduled run lands. Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U4) * ci(security): add zizmor workflow lint Lints .github/workflows/** for known Actions security misconfigurations (unpinned actions, dangerous interpolation, missing permissions). Triggered only on PRs touching .github/**. Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U5) * ci(security): add Trivy container image scanning Builds Dockerfile.cli and Dockerfile.web, then scans images for HIGH/CRITICAL CVEs. Findings record-only on Security tab; not PR-blocking. Weekly schedule + main push for freshness. Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U6) * docs(security): add SECURITY.md policy and Scorecard badge Vulnerability disclosure policy points to GitHub Private Vulnerability Reporting. Documents in-CI scans landed in this branch and recommended admin actions for forks. Plan: docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md (U7) * fix(review): apply autofix feedback - CodeQL paths-ignore: replace brace expansion (parser.{c,js}) with two explicit entries — CodeQL uses .gitignore-style globs that do NOT support brace expansion, so the original pattern matched no files. - Trivy: pin aquasecurity/trivy-action from @master to @0.28.0 — mutable refs are a supply-chain risk and are exactly what zizmor (added in this same plan) is meant to flag. ce-code-review run: /tmp/compound-engineering/ce-code-review/20260503-104259-279c3bc4/ * docs(review): record residual review findings ce-code-review autofix run flagged three downstream-resolver items that are not blockers but should land before promoting any of the new security workflows to required PR checks. Source: /tmp/compound-engineering/ce-code-review/20260503-104259-279c3bc4/ * fix(ci-security): address all zizmor + dependency-review violations Resolves all GitHub Advanced Security findings on PR #1297: - Add 'persist-credentials: false' to actions/checkout in 5 workflows (codeql, dependency-review, gitleaks, trivy, workflow-lint). Prevents the GITHUB_TOKEN from persisting in .git/config for downstream steps to read. Scorecard already had it. - Pin every net-new third-party Action to a commit SHA (was: major-tag refs flagged by zizmor as 'unpinned action reference'): github/codeql-action -> v3.35.3 (0daab03) actions/dependency-review-action -> v4.9.0 (2031cfc) gitleaks/gitleaks-action -> v2.3.9 (ff98106) ossf/scorecard-action -> v2.4.3 (4eaacf0) docker/build-push-action -> v6.19.2 (10e90e3) - Bump aquasecurity/trivy-action 0.28.0 -> 0.36.0 (ed142fd). Versions < 0.35.0 are flagged by GHSA-69fq-xp46-6x23 (briefly compromised supply chain). Caught by Dependency Review on the introducing PR. - Pin pipx-installed zizmor to 1.24.1 (was unpinned 'pipx install zizmor' resolving to latest at run time). Removes the now-stale residual-findings doc since every item it recorded is resolved on this branch. * fix(ci-security): clear remaining zizmor findings After landing the new security workflows, zizmor reported 5 high+ findings against pre-existing workflows (none introduced by this PR's new files, all introduced by zizmor's wider scope). Resolved per research at docs.zizmor.sh and PyO3/maturin issue #2425: Real fixes (cache-poisoning): - publish.yml + release-candidate.yml: add 'package-manager-cache: false' to actions/setup-node. setup-node v5+ enables caching by default when a packageManager field is present in package.json; explicit opt-out keeps release installs hermetic and clears the audit. Cost: ~30s slower per release run. Documented exemptions (dangerous-triggers, .github/zizmor.yml): - ci-report.yml: workflow_run is REQUIRED to post sticky comments on fork PRs (forks have read-only GITHUB_TOKEN on pull_request). - claude.yml: pull_request_target is required by claude-code-action to access secrets and post fork-PR review comments. PR checkouts pin fork HEAD SHA to mitigate TOCTOU. - pr-labeler.yml: pull_request_target on the autolabel job needs pull-requests:write. release-drafter runs with dry-run:true and reads config from the BASE ref only. Each exemption carries the documented mitigation in zizmor.yml. workflow-lint.yml now passes --config to both the SARIF and the gate invocations. Local 'zizmor --config .github/zizmor.yml --min-severity high .' reports: No findings to report. Good job! --- .github/workflows/codeql.yml | 67 +++++++++++++++++++++++ .github/workflows/dependency-review.yml | 36 ++++++++++++ .github/workflows/gitleaks.yml | 45 +++++++++++++++ .github/workflows/publish.yml | 8 ++- .github/workflows/release-candidate.yml | 7 ++- .github/workflows/scorecard.yml | 58 ++++++++++++++++++++ .github/workflows/trivy.yml | 73 +++++++++++++++++++++++++ .github/workflows/workflow-lint.yml | 58 ++++++++++++++++++++ .github/zizmor.yml | 34 ++++++++++++ README.md | 3 + SECURITY.md | 67 +++++++++++++++++++++++ 11 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/gitleaks.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 .github/workflows/trivy.yml create mode 100644 .github/workflows/workflow-lint.yml create mode 100644 .github/zizmor.yml create mode 100644 SECURITY.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..7df4acc8e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,67 @@ +name: CodeQL + +# Static analysis (SAST) for TypeScript/JavaScript and Python sources. +# Findings upload to the GitHub Security tab as SARIF. +# +# Advisory only on first introduction — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md. +# Promote to a required check after baseline triage (operator decision). + +on: + pull_request: + branches: [main] + paths-ignore: ['**.md', 'docs/**', 'LICENSE'] + push: + branches: [main] + schedule: + # Weekly Monday 06:00 UTC — catches advisories newly published against + # already-merged code without waiting for the next PR. + - cron: '0 6 * * 1' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + actions: read + contents: read + # security-events:write is what enables SARIF upload to the Security tab. + security-events: write + + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, python] + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Don't leave GITHUB_TOKEN in .git/config for downstream steps to read. + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + # Exclude generated/vendored code; tune after first-run signal. + # gitnexus/vendor/ holds tree-sitter-proto sources (regenerated, not authored). + # CodeQL path filters use .gitignore-style globs and do NOT support + # brace expansion — list each generated parser file separately. + config: | + paths-ignore: + - '**/dist/**' + - '**/node_modules/**' + - 'gitnexus/vendor/**' + - 'gitnexus/src/core/parsing/**/parser.c' + - 'gitnexus/src/core/parsing/**/parser.js' + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..ad06267c2 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,36 @@ +name: Dependency Review + +# Blocks PRs that introduce dependencies with high/critical known vulnerabilities. +# Reads the dependency graph diff between PR head and base. +# +# This is a required-check candidate after one week of clean runs +# (operator decision — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md). + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # pull-requests:write enables the inline summary comment on failure. + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Dependency Review + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 000000000..cfe4d0856 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,45 @@ +name: Gitleaks + +# Deterministic in-CI secret scanning. Defense-in-depth on top of GitHub's +# native secret-scanning push protection (which is a repo Settings toggle — +# see SECURITY.md for the recommended admin action). +# +# PR runs scan the diff (fast); main pushes scan full history. + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gitleaks: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Full history needed for the on-push full-history scan; on PRs the + # action diffs against the base ref so the cost is bounded by the PR. + fetch-depth: 0 + # Don't bake the token into the cloned .git/config; downstream + # steps (and Gitleaks itself) don't need it for repo operations. + persist-credentials: false + + # No GITLEAKS_LICENSE secret is required for OSS / public-repo usage. + # If this repo becomes private, the action will require a license key. + - name: Gitleaks + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true + GITLEAKS_ENABLE_SUMMARY: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1fc95a13a..d372c163a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,8 +37,12 @@ jobs: with: node-version: 20 registry-url: https://registry.npmjs.org - cache: npm - cache-dependency-path: gitnexus/package-lock.json + # Hermetic install for the published artifact — no cache carry-over + # from non-tag contexts. setup-node v5+ caches by default when a + # packageManager field is present in package.json, so the explicit + # opt-out is required to clear the zizmor cache-poisoning audit. + # ~30s slower per release; runs rarely. + package-manager-cache: false - name: Build gitnexus-shared run: npm install && npm run build working-directory: gitnexus-shared diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 7413a02e1..5b598ee14 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -137,8 +137,11 @@ jobs: with: node-version: 20 registry-url: https://registry.npmjs.org - cache: npm - cache-dependency-path: gitnexus/package-lock.json + # Hermetic install — release-candidate produces shipped artifacts. + # setup-node v5+ caches by default when a packageManager field is + # present in package.json; explicit opt-out is required to clear + # the zizmor cache-poisoning audit. See cache-poisoning audit. + package-manager-cache: false - name: Build gitnexus-shared run: npm install && npm run build diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..b132b7c3f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,58 @@ +name: Scorecard + +# OpenSSF Scorecard supply-chain posture check. Runs weekly + on main push + +# branch_protection_rule changes. SARIF uploads to the Security tab; the public +# badge URL resolves once the first scheduled run lands (see README badge wiring). + +on: + branch_protection_rule: + schedule: + - cron: '0 7 * * 1' + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + # Needed to upload SARIF results to the Security tab. + security-events: write + # Needed for the publish_results badge flow (OIDC). + id-token: write + contents: read + actions: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # publish_results enables the public Scorecard badge. + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to Security tab + uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..5bc5d17a4 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,73 @@ +name: Trivy Image Scan + +# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images +# for OS-package and language-package CVEs at HIGH/CRITICAL severity. +# Findings upload to the Security tab; record-only (does not block merges). +# +# NOT triggered on PRs — image builds are slow and base-image CVE churn +# shouldn't gate feature delivery. + +on: + push: + branches: [main] + schedule: + - cron: '0 8 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + scan: + name: Trivy (${{ matrix.image.name }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + image: + - { dockerfile: Dockerfile.cli, name: gitnexus-cli } + - { dockerfile: Dockerfile.web, name: gitnexus-web } + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build image (load locally for scan) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: ${{ matrix.image.dockerfile }} + load: true + push: false + tags: scan-target:${{ matrix.image.name }} + + # aquasecurity/trivy-action versions < 0.35.0 are flagged by + # GHSA-69fq-xp46-6x23 (briefly compromised supply chain). Pinned to + # v0.36.0 (post-incident clean release) by commit SHA. + - name: Run Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: scan-target:${{ matrix.image.name }} + format: sarif + output: trivy-${{ matrix.image.name }}.sarif + severity: HIGH,CRITICAL + # Hides CVEs with no available fix in the base image. + ignore-unfixed: true + exit-code: '0' + + - name: Upload to Security tab + uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3 + with: + sarif_file: trivy-${{ matrix.image.name }}.sarif + category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 000000000..3dd492406 --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,58 @@ +name: Workflow Lint (zizmor) + +# Lints .github/workflows/** for known GitHub Actions security misconfigurations: +# unpinned Actions, dangerous ${{ ... }} interpolation in run: blocks, +# missing per-job permissions:, etc. +# +# Scoped to PRs that touch .github/** only — keeps off the typical PR critical path. + +on: + pull_request: + branches: [main] + paths: + - '.github/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + zizmor: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + + - name: Install zizmor + # Pinned — resolves to whatever's latest on PyPI otherwise. + # Bump via Dependabot pip ecosystem (see .github/dependabot.yml). + run: pipx install zizmor==1.24.1 + + # Initial threshold: medium. High+ findings fail the job; medium findings + # appear in the Security tab without blocking. Tune after first run. + # Per-rule exemptions for pre-existing intentional patterns live in + # .github/zizmor.yml (each carries a documented mitigation). + - name: Run zizmor + run: zizmor --config .github/zizmor.yml --format sarif --min-severity medium . > zizmor.sarif + continue-on-error: true + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3 + with: + sarif_file: zizmor.sarif + category: zizmor + + - name: Fail on high+ findings + run: zizmor --config .github/zizmor.yml --min-severity high . diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 000000000..c534679c1 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,34 @@ +# zizmor config — pre-existing intentional patterns flagged on initial introduction. +# Each ignore below has a documented mitigation. Re-evaluate when the source workflow changes. +# +# To run zizmor locally with this config: +# zizmor --config .github/zizmor.yml . + +rules: + dangerous-triggers: + ignore: + # workflow_run is REQUIRED to post sticky comments on fork PRs — the + # default-branch privileged token isn't accessible from `pull_request` + # on a fork. Mitigated by: read-only `actions:read` + `contents:read` + # for artifact download; `pull-requests:write` is the only write scope; + # no checkout of fork code occurs. Header comment in the file documents. + - ci-report.yml + + # pull_request_target needed by claude-code-action to access secrets + # and post review comments on fork PRs. Mitigated by: PR checkouts pin + # the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races, + # and claude-code-action sandboxes execution. Header comment documents. + - claude.yml + + # pull_request_target on the autolabel job needs `pull-requests:write` + # to apply labels. Mitigated by: release-drafter runs with `dry-run: + # true`, reads only `.github/release-drafter.yml` from the BASE ref, + # and the validate-title job (which runs untrusted `pull_request` + # context) holds no write permissions. Header comment documents. + - pr-labeler.yml + + # Note: cache-poisoning is NOT exempted. The two prior findings in + # publish.yml and release-candidate.yml were fixed structurally by + # dropping `cache: npm` from those workflows (matches the pattern used + # by PyO3/maturin for the same audit). See the commit that added this + # file for the rationale. diff --git a/README.md b/README.md index ade7a4879..2dad7f2ef 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ License: PolyForm Noncommercial + + OpenSSF Scorecard +

Enterprise (SaaS & Self-hosted) - akonlabs.com

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..79ef97f6b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,67 @@ +# Security Policy + +## Supported Versions + +GitNexus is developed on `main`. Security fixes are applied to the latest released minor on npm (`gitnexus`) and to the published Docker images (`Dockerfile.cli`, `Dockerfile.web`). Older minors are not back-patched. + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security reports.** + +Use **GitHub Private Vulnerability Reporting** for this repository: + +→ https://github.com/abhigyanpatwari/GitNexus/security/advisories/new + +Please include: + +- A description of the issue and its potential impact +- Steps to reproduce (a minimal repro repo or commit hash if possible) +- The affected version(s) — `npm view gitnexus version`, image digest, or commit SHA +- Any suggested mitigation + +### What to expect + +- **Acknowledgement:** best-effort within 5 business days, subject to maintainer capacity. +- **Triage:** we will confirm whether the report is in scope, request clarifications if needed, and propose a fix timeline. +- **Disclosure:** coordinated. We will agree on a disclosure date with you before publishing an advisory. + +### Scope + +In scope: + +- The `gitnexus` CLI and MCP server (`gitnexus/`) +- The `gitnexus-web` thin client (`gitnexus-web/`) +- The `gitnexus-shared` types package (`gitnexus-shared/`) +- The published Docker images (`Dockerfile.cli`, `Dockerfile.web`) +- GitHub Actions workflows in `.github/workflows/` + +Out of scope: + +- Vulnerabilities in third-party dependencies that we have no influence over (please report upstream; if a viable mitigation exists at the GitNexus layer, that's in scope). +- Issues requiring physical access to a developer machine or a compromised local environment. +- Theoretical attacks without a practical exploit against a default GitNexus deployment. + +## Recommended Hardening for Forks and Self-Hosted Deployments + +If you fork GitNexus or self-host it, we recommend enabling the following in your repository's **Settings → Code security and analysis**: + +- **Private vulnerability reporting** — the channel described above. +- **Dependabot alerts** — alerts on advisories affecting your dependencies. +- **Dependabot security updates** — automated PRs for security patches (this repo's `.github/dependabot.yml` already covers version updates). +- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below. +- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place. + +## Automated Scans Running in CI + +This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab. + +| Scan | Tool | Trigger | Action on finding | +|------|------|---------|-------------------| +| Static analysis (JS/TS, Python) | [CodeQL](https://github.com/github/codeql-action) | PR, `main` push, weekly | Advisory (Security tab) | +| Dependency vulnerabilities (PR diff) | [`dependency-review-action`](https://github.com/actions/dependency-review-action) | PR | **Blocks PR** at `high+` severity | +| Secret scanning | [Gitleaks](https://github.com/gitleaks/gitleaks-action) | PR, `main` push | **Blocks PR** on default rules | +| Supply-chain posture | [OpenSSF Scorecard](https://github.com/ossf/scorecard-action) | Weekly, `main` push | Advisory (Security tab + public badge) | +| Workflow lint | [zizmor](https://github.com/woodruffw/zizmor) | PR (touching `.github/**`) | **Blocks PR** at `high+` severity | +| Container image scan | [Trivy](https://github.com/aquasecurity/trivy-action) | Weekly, `main` push | Advisory (Security tab) | + +Dependency version updates are managed separately by Dependabot — see `.github/dependabot.yml`. From 1272774ec21c82368325c152f928018d8c8e12af Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 08:25:38 +0100 Subject: [PATCH 04/12] fix(setup): prefer .cmd/.bat wrapper from Windows `where` output (#1299) * Initial plan * fix(setup): prefer .cmd wrapper from Windows `where` output On Windows, `where gitnexus` returns multiple entries including the POSIX shell script and the .cmd wrapper. The code previously took the first line (shell script), which cannot be spawned directly by Node.js child_process on Windows. Now we prefer the .cmd entry when available. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e6b54037-87fb-4195-b157-4cfcafce5f5d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(setup): also handle .bat wrappers and add fallback test Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e6b54037-87fb-4195-b157-4cfcafce5f5d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * revert package-lock.json and add CRLF/.bat/.CMD test variants - Revert package-lock.json to match base (no dependency changes needed) - Add CRLF line ending test (Windows `where` produces \r\n) - Add .bat wrapper test - Add uppercase .CMD extension test (case-insensitive regex) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7ed71368-b3e8-44de-9f13-85af4effaf25 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: format code --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/cli/setup.ts | 24 ++++++--- gitnexus/test/unit/setup.test.ts | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 9a2be4505..b301d7f38 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -32,15 +32,27 @@ interface SetupResult { */ function resolveGitnexusBin(): string | null { try { - const cmd = process.platform === 'win32' ? 'where' : 'which'; - const resolved = execFileSync(cmd, ['gitnexus'], { + const isWin = process.platform === 'win32'; + const cmd = isWin ? 'where' : 'which'; + const output = execFileSync(cmd, ['gitnexus'], { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], - }) - .split('\n')[0] - .trim(); - return resolved || null; + }); + const lines = output + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + + if (isWin) { + // On Windows, `where` returns multiple entries (e.g. the POSIX shell + // script AND the .cmd/.bat wrapper). Prefer the wrapper because + // child_process.spawn() cannot execute a shell script directly. + const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l)); + return cmdLine || lines[0] || null; + } + + return lines[0] || null; } catch { return null; } diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 4544ddb02..6cab31467 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -185,4 +185,95 @@ describe('setupClaudeCode', () => { args: ['-y', 'gitnexus@latest', 'mcp'], }); }); + + it('picks .cmd wrapper from Windows where output (multiple lines)', async () => { + setPlatform('win32'); + // `where gitnexus` on Windows returns the POSIX script first, then .cmd + execFileSyncMock.mockReturnValueOnce( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\n', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd', + args: ['mcp'], + }); + }); + + it('handles CRLF line endings from Windows where output', async () => { + setPlatform('win32'); + // Windows `where` produces CRLF line endings + execFileSyncMock.mockReturnValueOnce( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\r\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\r\n', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd', + args: ['mcp'], + }); + }); + + it('picks .bat wrapper when .cmd is not present', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat\n', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat', + args: ['mcp'], + }); + }); + + it('handles uppercase .CMD extension (case-insensitive match)', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD\n', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD', + args: ['mcp'], + }); + }); + + it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => { + setPlatform('win32'); + // Edge case: where returns only the POSIX script (no .cmd wrapper) + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus', + args: ['mcp'], + }); + }); }); From 6ec1f0460412235345ddf2f78e4082f608088bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 4 May 2026 09:35:34 +0100 Subject: [PATCH 05/12] chore(quality): exclude test/fixtures from CodeQL, ESLint, and Prettier (#1313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test fixtures are intentionally synthetic inputs (broken/unused code, malformed samples) used to exercise the analyzer. Quality-tool findings on them are noise, not real bugs — they were drowning out actionable signal in the GitHub Security tab. - CodeQL: add `**/test/fixtures/**` to paths-ignore in codeql.yml - ESLint: add `gitnexus-web/test/fixtures/**` to global ignores (the gitnexus/ counterpart was already ignored) - Prettier: add `gitnexus-web/test/fixtures/` to .prettierignore (same gap as ESLint) Real test files (*.test.ts) remain in scope so genuine issues like js/file-system-race and js/insecure-temporary-file in test code still surface. --- .github/workflows/codeql.yml | 4 ++++ .prettierignore | 1 + eslint.config.mjs | 1 + 3 files changed, 6 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7df4acc8e..eb6b08207 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -60,6 +60,10 @@ jobs: - 'gitnexus/vendor/**' - 'gitnexus/src/core/parsing/**/parser.c' - 'gitnexus/src/core/parsing/**/parser.js' + # Test fixtures are intentionally synthetic inputs (broken/unused + # code, malformed samples) used to exercise the analyzer. CodeQL + # findings here are noise, not real bugs. + - '**/test/fixtures/**' - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3 diff --git a/.prettierignore b/.prettierignore index 8c43748f4..cc6247d1f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ dist/ coverage/ gitnexus/vendor/ gitnexus/test/fixtures/ +gitnexus-web/test/fixtures/ gitnexus-web/playwright-report/ gitnexus-web/test-results/ *.d.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index cd7a331f2..5b365ba67 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ export default [ 'gitnexus/vendor/**', 'gitnexus-web/src/vendor/**', 'gitnexus/test/fixtures/**', + 'gitnexus-web/test/fixtures/**', 'gitnexus-web/playwright-report/**', 'gitnexus-web/test-results/**', '**/*.d.ts', From 7cce07b419bce83eef1fabc24f72db808e9bd35d Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 4 May 2026 10:43:21 +0200 Subject: [PATCH 06/12] feat(group): workspace extractors for Node, Python, Go, Java, Elixir (#1260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): auto-discover Node/TS workspace cross-package contracts Scan package.json dependencies and ES/CJS imports to find PascalCase type exports crossing workspace package boundaries. Same pipeline as Rust workspace extractor — emits GroupManifestLink[] with type:custom. Supports: ES named imports, default imports, CommonJS destructured require, scoped packages (@org/pkg), subpath imports, aliased imports. Filters to PascalCase names only (types/classes, not functions). * feat(group): auto-discover Python workspace cross-package contracts Scan pyproject.toml/setup.py dependencies and `from import` statements to find PascalCase type exports crossing workspace package boundaries. Handles hyphenated names (PEP 503 normalization), submodule imports, aliased imports, and optional-dependencies. * feat(group): auto-discover Go workspace cross-module contracts Scan go.mod require/replace directives and Go source files for exported PascalCase type usage (pkg.TypeName) crossing module boundaries within a group. Handles block syntax, subpackage imports, and local replace directives. * refactor(group): extract workspace discovery orchestrator from sync Move per-ecosystem workspace extractor calls into a single discoverWorkspaceLinks() orchestrator. Reduces sync.ts from 295 to 264 lines and gives a clean extension point for adding more ecosystem extractors. * feat(group): auto-discover Java/Kotlin workspace cross-project contracts Scan Maven pom.xml and Gradle build files for inter-project deps, then match Java/Kotlin import statements against known group-internal base packages. Supports Maven dependency blocks, Gradle coordinate and project() dependencies, static imports, and Kotlin files. * feat(group): auto-discover Elixir workspace cross-app contracts Scan mix.exs deps and Elixir source files for alias directives and direct module references crossing OTP app boundaries. Handles umbrella deps (in_umbrella), git/path deps, grouped aliases (alias MyApp.{ModA, ModB}), underscore-to-PascalCase app name mapping, and collapses nested submodules to top-level contracts. * fix(group): apply PR review fixes to all workspace extractors Address review findings from PR #1256 across Node, Python, Go, Java, and Elixir extractors: - Replace hardcoded IGNORE sets with shared IgnoreService (shouldIgnorePath + loadIgnoreRules) to honor .gitnexusignore - Qualify contract names with provider identifier to prevent contractId collisions across providers - Warn and skip duplicate project/module/app names - Update all test assertions for qualified contract format * fix(workspace): address review findings and fix CI - Fix prettier formatting on Rust workspace extractor files - Fix double readRegistry() call in syncGroup (hoist to function scope) - Fix console.warn spy leak in duplicate crate test (try/finally) - Add sync-level integration tests: workspace_deps true/false gating, Rust and Node link discovery through syncGroup orchestrator (3 tests) * style(workspace): fix Prettier formatting on all workspace extractors * fix(workspace): strip qualified prefix in custom contract resolution, default workspace_deps to false resolveSymbol for custom contracts now strips the "provider::" prefix before querying graph nodes, so workspace-generated contracts like "mathlex::Expression" correctly resolve to the "Expression" symbol. Change workspace_deps default from true to false for safe rollout — existing groups won't silently gain 6-ecosystem scans on upgrade. * fix(workspace): address medium review findings from PR #1260 - Elixir: strip comment lines before direct module reference scan to prevent false positives from commented-out module references - Go: use full module path for contract naming to avoid basename collisions between repos with identical last path segments - Sync tests: replace toBeGreaterThanOrEqual with exact toHaveLength assertions per DoD §2.7 - Add workspace_deps: false to makeConfig helper for type correctness - Add Elixir test proving comment-only references do not emit links * fix(workspace): address second-round medium review findings - Go: add test asserting aliased imports produce 0 links, guarding the V1 false-negative boundary at the assertion level - Elixir: add code comment documenting that contracts use full module names without appName:: prefix and that resolveSymbol resolution depends on Elixir indexer storing fully-qualified names * fix(workspace): eliminate regex backtracking in pyproject.toml parser CodeQL flagged exponential backtracking in the [project] name regex. Replace [^\[]*?\n (ambiguous lazy quantifier) with [^\n\[]*\n (atomic per-line match that still stops at section boundaries). * fix(test): use mkdtempSync for secure temp dir creation CodeQL flagged insecure temporary file creation (High) in sync.test.ts. Replace path.join(os.tmpdir(), predictable-name) + mkdirSync with fs.mkdtempSync which creates temp dirs atomically with random suffix, preventing symlink race conditions. --- gitnexus/src/core/group/config-parser.ts | 2 +- .../extractors/elixir-workspace-extractor.ts | 253 ++++++++++++++++ .../extractors/go-workspace-extractor.ts | 258 ++++++++++++++++ .../extractors/java-workspace-extractor.ts | 261 ++++++++++++++++ .../group/extractors/manifest-extractor.ts | 14 +- .../extractors/node-workspace-extractor.ts | 248 +++++++++++++++ .../extractors/python-workspace-extractor.ts | 254 ++++++++++++++++ .../group/extractors/workspace-extractor.ts | 90 ++++++ gitnexus/src/core/group/sync.ts | 12 +- .../group/elixir-workspace-extractor.test.ts | 261 ++++++++++++++++ .../unit/group/go-workspace-extractor.test.ts | 244 +++++++++++++++ .../group/java-workspace-extractor.test.ts | 240 +++++++++++++++ .../unit/group/manifest-extractor.test.ts | 44 ++- .../group/node-workspace-extractor.test.ts | 285 ++++++++++++++++++ .../group/python-workspace-extractor.test.ts | 241 +++++++++++++++ gitnexus/test/unit/group/sync.test.ts | 64 +++- 16 files changed, 2745 insertions(+), 26 deletions(-) create mode 100644 gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/go-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/java-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/node-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/python-workspace-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/workspace-extractor.ts create mode 100644 gitnexus/test/unit/group/elixir-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/go-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/java-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/node-workspace-extractor.test.ts create mode 100644 gitnexus/test/unit/group/python-workspace-extractor.test.ts diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index d55969a73..4703e6329 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -13,7 +13,7 @@ const DEFAULT_DETECT = { topics: true, shared_libs: true, embedding_fallback: true, - workspace_deps: true, + workspace_deps: false, }; const DEFAULT_MATCHING = { diff --git a/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts new file mode 100644 index 000000000..33afcaed9 --- /dev/null +++ b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts @@ -0,0 +1,253 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; +import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; + +interface ElixirAppMeta { + appName: string; + modulePrefix: string; + groupPath: string; + repoPath: string; + deps: string[]; +} + +interface ImportedModule { + appName: string; + moduleName: string; + filePath: string; +} + +async function parseMixExs( + repoPath: string, +): Promise<{ appName: string; modulePrefix: string; deps: string[] } | null> { + const mixPath = path.join(repoPath, 'mix.exs'); + let content: string; + try { + content = await fs.readFile(mixPath, 'utf-8'); + } catch { + return null; + } + + // app: :my_app + const appMatch = content.match(/app:\s*:(\w+)/); + if (!appMatch) return null; + const appName = appMatch[1]; + + // Derive module prefix: my_app -> MyApp + const modulePrefix = appName + .split('_') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + + const deps: string[] = []; + + // {:dep_name, "~> 1.0"} or {:dep_name, in_umbrella: true} + // {:dep_name, git: "..."} or {:dep_name, path: "..."} + const depMatches = content.matchAll( + /\{:(\w+)\s*,\s*(?:"[^"]*"|~[^}]*|[^}]*(?:in_umbrella|path|git)\s*:[^}]*)\}/g, + ); + for (const m of depMatches) { + deps.push(m[1]); + } + + return { appName, modulePrefix, deps: [...new Set(deps)] }; +} + +async function scanElixirImports( + repoPath: string, + knownApps: Map, +): Promise { + const results: ImportedModule[] = []; + const sourceFiles = await findElixirFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + // alias MyApp.SomeModule + // alias MyApp.SomeModule, as: Short + // alias MyApp.{ModA, ModB} + const aliasRegex = /^\s*alias\s+([A-Z]\w+(?:\.[A-Z]\w+)*(?:\.\{[^}]+\})?)/gm; + let match; + while ((match = aliasRegex.exec(content)) !== null) { + const aliasExpr = match[1]; + const modules = expandAlias(aliasExpr); + for (const mod of modules) { + const appName = matchModuleToApp(mod, knownApps); + if (appName) { + results.push({ appName, moduleName: mod, filePath: relFile }); + } + } + } + + // Direct module reference: MyApp.Module.func() or MyApp.Module + // Strip comment lines and string literals to avoid false positives + const codeOnly = content + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')) + .join('\n'); + for (const [prefix, appName] of knownApps) { + const refRegex = new RegExp( + `\\b(${escapeRegex(prefix)}\\.[A-Z][A-Za-z0-9]*(?:\\.[A-Z][A-Za-z0-9]*)*)`, + 'g', + ); + while ((match = refRegex.exec(codeOnly)) !== null) { + const mod = match[1]; + if (!results.some((r) => r.moduleName === mod && r.filePath === relFile)) { + results.push({ appName, moduleName: mod, filePath: relFile }); + } + } + } + } + + return results; +} + +function expandAlias(expr: string): string[] { + const braceMatch = expr.match(/^([A-Z][\w.]*)\.\{([^}]+)\}$/); + if (braceMatch) { + const prefix = braceMatch[1]; + return braceMatch[2] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => `${prefix}.${s}`); + } + return [expr]; +} + +function matchModuleToApp(moduleName: string, knownApps: Map): string | null { + for (const [prefix, appName] of knownApps) { + if (moduleName === prefix || moduleName.startsWith(prefix + '.')) { + return appName; + } + } + return null; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function extractTopModule(moduleName: string, prefix: string): string { + const rest = moduleName.slice(prefix.length); + if (!rest || rest === '.') return moduleName; + const afterDot = rest.startsWith('.') ? rest.slice(1) : rest; + const parts = afterDot.split('.'); + return `${prefix}.${parts[0]}`; +} + +async function findElixirFiles(repoPath: string): Promise { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.ex') || entry.name.endsWith('.exs')) { + if (entry.name === 'mix.exs' || entry.name === 'mix.lock') continue; + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel)) continue; + results.push(childRel); + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface ElixirWorkspaceResult { + links: GroupManifestLink[]; + discoveredApps: Map; +} + +export async function extractElixirWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const appsByName = new Map(); + const appsByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parseMixExs(repoPath); + if (!manifest) continue; + + const meta: ElixirAppMeta = { + appName: manifest.appName, + modulePrefix: manifest.modulePrefix, + groupPath, + repoPath, + deps: manifest.deps, + }; + const existing = appsByName.get(manifest.appName); + if (existing) { + console.warn( + `[elixir-workspace-extractor] duplicate app "${manifest.appName}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`, + ); + continue; + } + appsByName.set(manifest.appName, meta); + appsByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, app] of appsByGroupPath) { + const groupDeps = app.deps.filter((d) => appsByName.has(d)); + if (groupDeps.length === 0) continue; + + const knownApps = new Map(); + for (const dep of groupDeps) { + const depMeta = appsByName.get(dep); + if (depMeta) knownApps.set(depMeta.modulePrefix, dep); + } + + const imports = await scanElixirImports(app.repoPath, knownApps); + + for (const imp of imports) { + const providerApp = appsByName.get(imp.appName); + if (!providerApp) continue; + + const topModule = extractTopModule(imp.moduleName, providerApp.modulePrefix); + const key = `${app.groupPath}→${providerApp.groupPath}::${topModule}`; + if (seen.has(key)) continue; + seen.add(key); + + // V1: Elixir contracts use the full module name (e.g. "Core.Schema") without + // an "appName::" prefix. resolveSymbol will query the graph with this full + // string — resolution depends on Elixir indexer storing fully-qualified names. + const link: GroupManifestLink = { + from: providerApp.groupPath, + to: app.groupPath, + type: 'custom', + contract: topModule, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredApps: appsByGroupPath }; +} diff --git a/gitnexus/src/core/group/extractors/go-workspace-extractor.ts b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts new file mode 100644 index 000000000..fbdf0e450 --- /dev/null +++ b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts @@ -0,0 +1,258 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; +import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; + +interface GoModuleMeta { + modulePath: string; + groupPath: string; + repoPath: string; + requires: string[]; +} + +interface ImportedSymbol { + modulePath: string; + symbolName: string; + filePath: string; +} + +async function parseGoMod( + repoPath: string, +): Promise<{ modulePath: string; requires: string[] } | null> { + const goModPath = path.join(repoPath, 'go.mod'); + let content: string; + try { + content = await fs.readFile(goModPath, 'utf-8'); + } catch { + return null; + } + + const moduleMatch = content.match(/^module\s+(\S+)/m); + if (!moduleMatch) return null; + const modulePath = moduleMatch[1]; + + const requires: string[] = []; + + // Single-line: require github.com/org/repo v1.2.3 + const singleReqs = content.matchAll(/^require\s+(\S+)\s+/gm); + for (const m of singleReqs) requires.push(m[1]); + + // Block: require ( ... ) + const blockReqs = content.matchAll(/^require\s*\(\s*\n([\s\S]*?)\)/gm); + for (const block of blockReqs) { + const lines = block[1].split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) continue; + const parts = trimmed.split(/\s+/); + if (parts[0]) requires.push(parts[0]); + } + } + + // replace directives (local path deps) + const replaceLines = content.matchAll(/^replace\s+(\S+)\s+=>\s+\.\//gm); + for (const m of replaceLines) { + if (!requires.includes(m[1])) requires.push(m[1]); + } + + const replaceBlocks = content.matchAll(/^replace\s*\(\s*\n([\s\S]*?)\)/gm); + for (const block of replaceBlocks) { + const lines = block[1].split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) continue; + const match = trimmed.match(/^(\S+)\s+=>\s+\.\//); + if (match && !requires.includes(match[1])) requires.push(match[1]); + } + } + + return { modulePath, requires: [...new Set(requires)] }; +} + +async function scanGoImports( + repoPath: string, + knownModules: Map, +): Promise { + const results: ImportedSymbol[] = []; + const sourceFiles = await findGoFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + const importPaths = extractImportPaths(content); + for (const importPath of importPaths) { + const matchedModule = findMatchingModule(importPath, knownModules); + if (!matchedModule) continue; + + const symbols = extractUsedTypes(content, importPath); + for (const sym of symbols) { + results.push({ modulePath: matchedModule, symbolName: sym, filePath: relFile }); + } + } + } + + return results; +} + +function extractImportPaths(content: string): string[] { + const paths: string[] = []; + + // Single: import "path" + const singleImports = content.matchAll(/^import\s+"([^"]+)"/gm); + for (const m of singleImports) paths.push(m[1]); + + // Single aliased: import alias "path" + const aliasedImports = content.matchAll(/^import\s+\w+\s+"([^"]+)"/gm); + for (const m of aliasedImports) paths.push(m[1]); + + // Block: import ( ... ) + const blockImports = content.matchAll(/^import\s*\(\s*\n([\s\S]*?)\)/gm); + for (const block of blockImports) { + const lines = block[1].split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) continue; + const pathMatch = trimmed.match(/"([^"]+)"/); + if (pathMatch) paths.push(pathMatch[1]); + } + } + + return [...new Set(paths)]; +} + +function findMatchingModule(importPath: string, knownModules: Map): string | null { + for (const [modPath] of knownModules) { + if (importPath === modPath || importPath.startsWith(modPath + '/')) { + return modPath; + } + } + return null; +} + +function extractUsedTypes(content: string, importPath: string): string[] { + const pkgName = importPath.split('/').pop() || ''; + if (!pkgName) return []; + + // Match pkg.TypeName where TypeName is PascalCase (exported) + const typeRegex = new RegExp(`\\b${escapeRegex(pkgName)}\\.([A-Z][A-Za-z0-9]*)`, 'g'); + const types = new Set(); + let match; + while ((match = typeRegex.exec(content)) !== null) { + types.add(match[1]); + } + return [...types]; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function findGoFiles(repoPath: string): Promise { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel)) continue; + results.push(childRel); + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface GoWorkspaceResult { + links: GroupManifestLink[]; + discoveredModules: Map; +} + +export async function extractGoWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const modulesByPath = new Map(); + const modulesByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parseGoMod(repoPath); + if (!manifest) continue; + + const meta: GoModuleMeta = { + modulePath: manifest.modulePath, + groupPath, + repoPath, + requires: manifest.requires, + }; + const existing = modulesByPath.get(manifest.modulePath); + if (existing) { + console.warn( + `[go-workspace-extractor] duplicate module "${manifest.modulePath}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`, + ); + continue; + } + modulesByPath.set(manifest.modulePath, meta); + modulesByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, mod] of modulesByGroupPath) { + const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r)); + if (groupModDeps.length === 0) continue; + + const knownModules = new Map(); + for (const dep of groupModDeps) { + knownModules.set(dep, dep); + } + + const imports = await scanGoImports(mod.repoPath, knownModules); + + for (const imp of imports) { + const providerMod = modulesByPath.get(imp.modulePath); + if (!providerMod) continue; + + const qualifiedContract = `${imp.modulePath}::${imp.symbolName}`; + const key = `${mod.groupPath}→${providerMod.groupPath}::${qualifiedContract}`; + if (seen.has(key)) continue; + seen.add(key); + + const link: GroupManifestLink = { + from: providerMod.groupPath, + to: mod.groupPath, + type: 'custom', + contract: qualifiedContract, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredModules: modulesByGroupPath }; +} diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts new file mode 100644 index 000000000..66ee35ee5 --- /dev/null +++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts @@ -0,0 +1,261 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; +import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; + +interface JavaProjectMeta { + groupId: string; + artifactId: string; + basePackage: string; + groupPath: string; + repoPath: string; + deps: string[]; +} + +interface ImportedSymbol { + artifactKey: string; + symbolName: string; + filePath: string; +} + +async function parseJavaManifest( + repoPath: string, +): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> { + const pomPath = path.join(repoPath, 'pom.xml'); + try { + const content = await fs.readFile(pomPath, 'utf-8'); + return parsePom(content); + } catch { + // fall through to Gradle + } + + for (const name of ['build.gradle.kts', 'build.gradle']) { + const gradlePath = path.join(repoPath, name); + try { + const content = await fs.readFile(gradlePath, 'utf-8'); + return parseGradle(content, repoPath); + } catch { + continue; + } + } + + return null; +} + +function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null { + const projectGroupMatch = content.match(/]*>[\s\S]*?([^<]+)<\/groupId>/); + const projectArtifactMatch = content.match( + /]*>[\s\S]*?([^<]+)<\/artifactId>/, + ); + if (!projectGroupMatch || !projectArtifactMatch) return null; + + const groupId = projectGroupMatch[1].trim(); + const artifactId = projectArtifactMatch[1].trim(); + + const deps: string[] = []; + const depBlocks = content.matchAll(/\s*([\s\S]*?)<\/dependency>/g); + for (const block of depBlocks) { + const gMatch = block[1].match(/([^<]+)<\/groupId>/); + const aMatch = block[1].match(/([^<]+)<\/artifactId>/); + if (gMatch && aMatch) { + deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`); + } + } + + return { groupId, artifactId, deps: [...new Set(deps)] }; +} + +function parseGradle( + content: string, + repoPath: string, +): { groupId: string; artifactId: string; deps: string[] } | null { + const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/); + const dirName = path.basename(repoPath); + const groupId = groupMatch ? groupMatch[1] : ''; + if (!groupId) return null; + + const artifactId = dirName; + + const deps: string[] = []; + // implementation("group:artifact:version") or api("group:artifact:version") + const depMatches = content.matchAll( + /(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ); + for (const m of depMatches) { + const parts = m[1].split(':'); + if (parts.length >= 2) { + deps.push(`${parts[0]}:${parts[1]}`); + } + } + + // implementation(project(":subproject")) + const projDeps = content.matchAll( + /(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g, + ); + for (const m of projDeps) { + const subName = m[1].replace(/^:/, ''); + deps.push(`${groupId}:${subName}`); + } + + return { groupId, artifactId, deps: [...new Set(deps)] }; +} + +function deriveBasePackage(groupId: string, artifactId: string): string { + const sanitized = artifactId.replace(/-/g, '.'); + if (groupId.endsWith(`.${sanitized}`) || groupId === sanitized) { + return groupId; + } + return `${groupId}.${sanitized}`; +} + +async function scanJavaImports( + repoPath: string, + knownPackages: Map, +): Promise { + const results: ImportedSymbol[] = []; + const sourceFiles = await findJavaFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + const importRegex = /^import\s+(?:static\s+)?([a-zA-Z][\w.]*\.[A-Z]\w*)/gm; + let match; + while ((match = importRegex.exec(content)) !== null) { + const fullImport = match[1]; + for (const [basePkg, artifactKey] of knownPackages) { + if (fullImport.startsWith(basePkg + '.') || fullImport === basePkg) { + const parts = fullImport.split('.'); + const className = parts[parts.length - 1]; + if (isPascalCase(className)) { + results.push({ + artifactKey, + symbolName: className, + filePath: relFile, + }); + } + break; + } + } + } + } + + return results; +} + +function isPascalCase(name: string): boolean { + return /^[A-Z][A-Za-z0-9]*$/.test(name); +} + +async function findJavaFiles(repoPath: string): Promise { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.java') || entry.name.endsWith('.kt')) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel)) continue; + results.push(childRel); + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface JavaWorkspaceResult { + links: GroupManifestLink[]; + discoveredProjects: Map; +} + +export async function extractJavaWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const projectsByKey = new Map(); + const projectsByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parseJavaManifest(repoPath); + if (!manifest) continue; + + const key = `${manifest.groupId}:${manifest.artifactId}`; + const meta: JavaProjectMeta = { + groupId: manifest.groupId, + artifactId: manifest.artifactId, + basePackage: deriveBasePackage(manifest.groupId, manifest.artifactId), + groupPath, + repoPath, + deps: manifest.deps, + }; + const existing = projectsByKey.get(key); + if (existing) { + console.warn( + `[java-workspace-extractor] duplicate artifact "${key}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`, + ); + continue; + } + projectsByKey.set(key, meta); + projectsByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, proj] of projectsByGroupPath) { + const groupDeps = proj.deps.filter((d) => projectsByKey.has(d)); + if (groupDeps.length === 0) continue; + + const knownPackages = new Map(); + for (const dep of groupDeps) { + const depMeta = projectsByKey.get(dep); + if (depMeta) knownPackages.set(depMeta.basePackage, dep); + } + + const imports = await scanJavaImports(proj.repoPath, knownPackages); + + for (const imp of imports) { + const providerProj = projectsByKey.get(imp.artifactKey); + if (!providerProj) continue; + + const qualifiedContract = `${providerProj.artifactId}::${imp.symbolName}`; + const dedupKey = `${proj.groupPath}→${providerProj.groupPath}::${qualifiedContract}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + const link: GroupManifestLink = { + from: providerProj.groupPath, + to: proj.groupPath, + type: 'custom', + contract: qualifiedContract, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredProjects: projectsByGroupPath }; +} diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index b65b7712d..3185f05e1 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -269,17 +269,19 @@ export class ManifestExtractor { { contract: link.contract }, ); } else if (link.type === 'custom') { - // V1: exact name-only match on code-definition nodes. - // Positive allowlist mirrors other contract types. If multiple code - // symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks - // the alphabetically-first occurrence deterministically. + // Workspace extractors produce qualified contracts like "mathlex::Expression". + // Graph nodes store the unqualified symbol name ("Expression"), so strip + // the "provider::" prefix before querying. + const symbolName = link.contract.includes('::') + ? link.contract.split('::').pop()! + : link.contract; rows = await executor( `MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement) - WHERE n.name = $contract + WHERE n.name = $symbolName RETURN n.id AS uid, n.name AS name, n.filePath AS filePath ORDER BY n.filePath ASC LIMIT 1`, - { contract: link.contract }, + { symbolName }, ); } else { return null; diff --git a/gitnexus/src/core/group/extractors/node-workspace-extractor.ts b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts new file mode 100644 index 000000000..05a7c95dd --- /dev/null +++ b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts @@ -0,0 +1,248 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; +import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; + +interface PackageMeta { + name: string; + groupPath: string; + repoPath: string; + workspaceDeps: string[]; +} + +interface ImportedSymbol { + packageName: string; + symbolName: string; + filePath: string; +} + +async function parsePackageManifest( + repoPath: string, +): Promise<{ name: string; workspaceDeps: string[] } | null> { + const pkgPath = path.join(repoPath, 'package.json'); + let content: string; + try { + content = await fs.readFile(pkgPath, 'utf-8'); + } catch { + return null; + } + + let pkg: Record; + try { + pkg = JSON.parse(content); + } catch { + return null; + } + + const name = typeof pkg.name === 'string' ? pkg.name : ''; + if (!name) return null; + + const deps: string[] = []; + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + const section = pkg[field]; + if (section && typeof section === 'object') { + deps.push(...Object.keys(section as Record)); + } + } + + return { name, workspaceDeps: [...new Set(deps)] }; +} + +async function scanImports( + repoPath: string, + knownPackages: Set, +): Promise { + const results: ImportedSymbol[] = []; + const sourceFiles = await findSourceFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + // ES import: import { Foo, Bar } from '' + // Also: import { Foo as Baz } from '' + const esImportRegex = /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm; + let match; + while ((match = esImportRegex.exec(content)) !== null) { + const importClause = match[1]; + const modulePath = match[2]; + const pkgName = resolvePackageName(modulePath); + if (!pkgName || !knownPackages.has(pkgName)) continue; + + const symbols = parseImportClause(importClause); + for (const sym of symbols) { + if (isExportedName(sym)) { + results.push({ packageName: pkgName, symbolName: sym, filePath: relFile }); + } + } + } + + // ES import default: import Foo from '' + const defaultImportRegex = /^import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]([^'"]+)['"]/gm; + while ((match = defaultImportRegex.exec(content)) !== null) { + const symbolName = match[1]; + const modulePath = match[2]; + const pkgName = resolvePackageName(modulePath); + if (!pkgName || !knownPackages.has(pkgName)) continue; + + if (isExportedName(symbolName)) { + results.push({ packageName: pkgName, symbolName, filePath: relFile }); + } + } + + // CommonJS: const { Foo, Bar } = require('') + const cjsRegex = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm; + while ((match = cjsRegex.exec(content)) !== null) { + const importClause = match[1]; + const modulePath = match[2]; + const pkgName = resolvePackageName(modulePath); + if (!pkgName || !knownPackages.has(pkgName)) continue; + + const symbols = parseImportClause(importClause); + for (const sym of symbols) { + if (isExportedName(sym)) { + results.push({ packageName: pkgName, symbolName: sym, filePath: relFile }); + } + } + } + } + + return results; +} + +function resolvePackageName(modulePath: string): string | null { + if (modulePath.startsWith('.') || modulePath.startsWith('/')) return null; + // Scoped: @scope/pkg or @scope/pkg/sub + if (modulePath.startsWith('@')) { + const parts = modulePath.split('/'); + if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; + return null; + } + // Unscoped: pkg or pkg/sub + return modulePath.split('/')[0]; +} + +function parseImportClause(clause: string): string[] { + return clause + .split(',') + .map((s) => { + const trimmed = s.trim(); + // Handle `Foo as Bar` — use the original export name + const asMatch = trimmed.match(/^(\S+)\s+as\s+/); + return asMatch ? asMatch[1] : trimmed; + }) + .filter(Boolean); +} + +function isExportedName(name: string): boolean { + return /^[A-Z][A-Za-z0-9]*$/.test(name); +} + +async function findSourceFiles(repoPath: string): Promise { + const results: string[] = []; + const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']); + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else { + const ext = path.extname(entry.name); + if (EXTENSIONS.has(ext)) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel)) continue; + results.push(childRel); + } + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface NodeWorkspaceResult { + links: GroupManifestLink[]; + discoveredPackages: Map; +} + +export async function extractNodeWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const packagesByName = new Map(); + const packagesByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parsePackageManifest(repoPath); + if (!manifest) continue; + + const meta: PackageMeta = { + name: manifest.name, + groupPath, + repoPath, + workspaceDeps: manifest.workspaceDeps, + }; + const existing = packagesByName.get(manifest.name); + if (existing) { + console.warn( + `[node-workspace-extractor] duplicate package name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`, + ); + continue; + } + packagesByName.set(manifest.name, meta); + packagesByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, pkg] of packagesByGroupPath) { + const groupPkgDeps = pkg.workspaceDeps.filter((d) => packagesByName.has(d)); + if (groupPkgDeps.length === 0) continue; + + const knownPackages = new Set(groupPkgDeps); + const imports = await scanImports(pkg.repoPath, knownPackages); + + for (const imp of imports) { + const providerPkg = packagesByName.get(imp.packageName); + if (!providerPkg) continue; + + const qualifiedContract = `${imp.packageName}::${imp.symbolName}`; + const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`; + if (seen.has(key)) continue; + seen.add(key); + + const link: GroupManifestLink = { + from: providerPkg.groupPath, + to: pkg.groupPath, + type: 'custom', + contract: qualifiedContract, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredPackages: packagesByGroupPath }; +} diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts new file mode 100644 index 000000000..5930808af --- /dev/null +++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts @@ -0,0 +1,254 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink, ContractRole } from '../types.js'; +import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; + +interface PythonPackageMeta { + name: string; + importName: string; + groupPath: string; + repoPath: string; + workspaceDeps: string[]; +} + +interface ImportedSymbol { + packageName: string; + symbolName: string; + filePath: string; +} + +async function parsePythonManifest( + repoPath: string, +): Promise<{ name: string; importName: string; deps: string[] } | null> { + const pyprojectPath = path.join(repoPath, 'pyproject.toml'); + let content: string | null = null; + try { + content = await fs.readFile(pyprojectPath, 'utf-8'); + } catch { + // fall through to setup.py + } + + if (content) return parsePyproject(content); + + const setupPyPath = path.join(repoPath, 'setup.py'); + try { + content = await fs.readFile(setupPyPath, 'utf-8'); + } catch { + return null; + } + return parseSetupPy(content); +} + +function parsePyproject( + content: string, +): { name: string; importName: string; deps: string[] } | null { + const nameMatch = content.match(/^\[project\]\s*\n(?:[^\n\[]*\n)*?name\s*=\s*"([^"]+)"/m); + if (!nameMatch) return null; + const name = nameMatch[1]; + const importName = name.replace(/-/g, '_'); + + const deps: string[] = []; + const depsMatch = content.match(/^\[project\]\s*\n[\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/m); + if (depsMatch) { + const depLines = depsMatch[1].matchAll(/"([^"]+)"/g); + for (const m of depLines) { + deps.push(extractPepName(m[1])); + } + } + + const optMatch = content.match(/\[project\.optional-dependencies\]\s*\n([\s\S]*?)(?=\n\[|$)/); + if (optMatch) { + const optDeps = optMatch[1].matchAll(/"([^"]+)"/g); + for (const m of optDeps) { + deps.push(extractPepName(m[1])); + } + } + + return { name, importName, deps: [...new Set(deps)] }; +} + +function parseSetupPy( + content: string, +): { name: string; importName: string; deps: string[] } | null { + const nameMatch = content.match(/name\s*=\s*['"]([^'"]+)['"]/); + if (!nameMatch) return null; + const name = nameMatch[1]; + const importName = name.replace(/-/g, '_'); + + const deps: string[] = []; + const installMatch = content.match(/install_requires\s*=\s*\[([\s\S]*?)\]/); + if (installMatch) { + const depLines = installMatch[1].matchAll(/['"]([^'"]+)['"]/g); + for (const m of depLines) { + deps.push(extractPepName(m[1])); + } + } + + return { name, importName, deps: [...new Set(deps)] }; +} + +function extractPepName(spec: string): string { + return spec.split(/[><=!~;\[]/)[0].trim(); +} + +async function scanPythonImports( + repoPath: string, + knownPackages: Map, +): Promise { + const results: ImportedSymbol[] = []; + const sourceFiles = await findPythonFiles(repoPath); + + for (const relFile of sourceFiles) { + const absPath = path.join(repoPath, relFile); + let content: string; + try { + content = await fs.readFile(absPath, 'utf-8'); + } catch { + continue; + } + + // from import Foo, Bar + // from .module import Foo + const fromImportRegex = /^from\s+(\w[\w.]*)\s+import\s+(.+)/gm; + let match; + while ((match = fromImportRegex.exec(content)) !== null) { + const modulePath = match[1]; + const importClause = match[2]; + const rootModule = modulePath.split('.')[0]; + const originalName = knownPackages.get(rootModule); + if (!originalName) continue; + + if (importClause.trim() === '(') continue; + + const symbols = importClause + .replace(/\(|\)/g, '') + .split(',') + .map((s) => { + const trimmed = s.trim(); + const asMatch = trimmed.match(/^(\S+)\s+as\s+/); + return asMatch ? asMatch[1] : trimmed; + }) + .filter(Boolean); + + for (const sym of symbols) { + if (isPascalCase(sym)) { + results.push({ packageName: originalName, symbolName: sym, filePath: relFile }); + } + } + } + } + + return results; +} + +function isPascalCase(name: string): boolean { + return /^[A-Z][A-Za-z0-9]*$/.test(name); +} + +async function findPythonFiles(repoPath: string): Promise { + const results: string[] = []; + const ig = await loadIgnoreRules(repoPath); + + async function walk(dir: string, rel: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel + '/')) continue; + await walk(path.join(dir, entry.name), childRel); + } else if (entry.name.endsWith('.py')) { + if (shouldIgnorePath(childRel)) continue; + if (ig && ig.ignores(childRel)) continue; + results.push(childRel); + } + } + } + + await walk(repoPath, ''); + return results; +} + +export interface PythonWorkspaceResult { + links: GroupManifestLink[]; + discoveredPackages: Map; +} + +export async function extractPythonWorkspaceLinks( + repos: Record, + repoPaths: Map, + _dbExecutors?: Map, +): Promise { + const packagesByImportName = new Map(); + const packagesByGroupPath = new Map(); + + for (const [groupPath] of Object.entries(repos)) { + const repoPath = repoPaths.get(groupPath); + if (!repoPath) continue; + + const manifest = await parsePythonManifest(repoPath); + if (!manifest) continue; + + const meta: PythonPackageMeta = { + name: manifest.name, + importName: manifest.importName, + groupPath, + repoPath, + workspaceDeps: manifest.deps, + }; + const existing = packagesByImportName.get(manifest.importName); + if (existing) { + console.warn( + `[python-workspace-extractor] duplicate package "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`, + ); + continue; + } + packagesByImportName.set(manifest.importName, meta); + packagesByGroupPath.set(groupPath, meta); + } + + const links: GroupManifestLink[] = []; + const seen = new Set(); + + for (const [, pkg] of packagesByGroupPath) { + const normalizedDeps = pkg.workspaceDeps.map((d) => d.replace(/-/g, '_')); + const groupPkgDeps = normalizedDeps.filter((d) => packagesByImportName.has(d)); + if (groupPkgDeps.length === 0) continue; + + const knownPackages = new Map(); + for (const dep of groupPkgDeps) { + const meta = packagesByImportName.get(dep); + if (meta) knownPackages.set(dep, meta.name); + } + + const imports = await scanPythonImports(pkg.repoPath, knownPackages); + + for (const imp of imports) { + const providerImportName = imp.packageName.replace(/-/g, '_'); + const providerPkg = packagesByImportName.get(providerImportName); + if (!providerPkg) continue; + + const qualifiedContract = `${providerPkg.name}::${imp.symbolName}`; + const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`; + if (seen.has(key)) continue; + seen.add(key); + + const link: GroupManifestLink = { + from: providerPkg.groupPath, + to: pkg.groupPath, + type: 'custom', + contract: qualifiedContract, + role: 'provider' as ContractRole, + }; + links.push(link); + } + } + + return { links, discoveredPackages: packagesByGroupPath }; +} diff --git a/gitnexus/src/core/group/extractors/workspace-extractor.ts b/gitnexus/src/core/group/extractors/workspace-extractor.ts new file mode 100644 index 000000000..652f06e46 --- /dev/null +++ b/gitnexus/src/core/group/extractors/workspace-extractor.ts @@ -0,0 +1,90 @@ +import type { CypherExecutor } from '../contract-extractor.js'; +import type { GroupManifestLink } from '../types.js'; +import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js'; +import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js'; +import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js'; +import { extractGoWorkspaceLinks } from './go-workspace-extractor.js'; +import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js'; +import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js'; + +export interface WorkspaceDiscoveryResult { + links: GroupManifestLink[]; + stats: WorkspaceExtractorStats[]; +} + +interface WorkspaceExtractorStats { + ecosystem: string; + linkCount: number; + projectCount: number; +} + +export async function discoverWorkspaceLinks( + repos: Record, + repoPaths: Map, + dbExecutors?: Map, +): Promise { + const links: GroupManifestLink[] = []; + const stats: WorkspaceExtractorStats[] = []; + + const rustResult = await extractRustWorkspaceLinks(repos, repoPaths, dbExecutors); + if (rustResult.links.length > 0) { + links.push(...rustResult.links); + stats.push({ + ecosystem: 'Rust', + linkCount: rustResult.links.length, + projectCount: rustResult.discoveredCrates.size, + }); + } + + const nodeResult = await extractNodeWorkspaceLinks(repos, repoPaths, dbExecutors); + if (nodeResult.links.length > 0) { + links.push(...nodeResult.links); + stats.push({ + ecosystem: 'Node', + linkCount: nodeResult.links.length, + projectCount: nodeResult.discoveredPackages.size, + }); + } + + const pyResult = await extractPythonWorkspaceLinks(repos, repoPaths, dbExecutors); + if (pyResult.links.length > 0) { + links.push(...pyResult.links); + stats.push({ + ecosystem: 'Python', + linkCount: pyResult.links.length, + projectCount: pyResult.discoveredPackages.size, + }); + } + + const goResult = await extractGoWorkspaceLinks(repos, repoPaths, dbExecutors); + if (goResult.links.length > 0) { + links.push(...goResult.links); + stats.push({ + ecosystem: 'Go', + linkCount: goResult.links.length, + projectCount: goResult.discoveredModules.size, + }); + } + + const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors); + if (javaResult.links.length > 0) { + links.push(...javaResult.links); + stats.push({ + ecosystem: 'Java', + linkCount: javaResult.links.length, + projectCount: javaResult.discoveredProjects.size, + }); + } + + const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors); + if (elixirResult.links.length > 0) { + links.push(...elixirResult.links); + stats.push({ + ecosystem: 'Elixir', + linkCount: elixirResult.links.length, + projectCount: elixirResult.discoveredApps.size, + }); + } + + return { links, stats }; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index a9ecb51f4..09b289033 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -8,7 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; -import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js'; +import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js'; import { runExactMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; @@ -193,13 +193,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis if (e) repoPaths.set(groupPath, e.path); } - const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors); + const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors); if (wsResult.links.length > 0) { allLinks = [...allLinks, ...wsResult.links]; if (opts?.verbose) { - console.log( - ` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`, - ); + for (const s of wsResult.stats) { + console.log( + ` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`, + ); + } } } } diff --git a/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts new file mode 100644 index 000000000..f7af3861c --- /dev/null +++ b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { extractElixirWorkspaceLinks } from '../../../src/core/group/extractors/elixir-workspace-extractor.js'; + +describe('ElixirWorkspaceExtractor', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ex-ws-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeFile(relPath: string, content: string) { + const absPath = path.join(tmpDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf-8'); + } + + it('discovers cross-app alias imports', async () => { + await writeFile( + 'core/mix.exs', + 'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('core/lib/core/schema.ex', 'defmodule Core.Schema do\nend\n'); + + await writeFile( + 'web/mix.exs', + 'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n', + ); + await writeFile( + 'web/lib/web/controller.ex', + 'defmodule Web.Controller do\n alias Core.Schema\nend\n', + ); + + const repos = { core: 'core', web: 'web' }; + const repoPaths = new Map([ + ['core', path.join(tmpDir, 'core')], + ['web', path.join(tmpDir, 'web')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0]).toEqual({ + from: 'core', + to: 'web', + type: 'custom', + contract: 'Core.Schema', + role: 'provider', + }); + }); + + it('handles grouped alias (alias MyApp.{ModA, ModB})', async () => { + await writeFile( + 'shared/mix.exs', + 'defmodule Shared.MixProject do\n use Mix.Project\n def project do\n [app: :shared, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('shared/lib/shared/config.ex', 'defmodule Shared.Config do\nend\n'); + await writeFile('shared/lib/shared/logger.ex', 'defmodule Shared.Logger do\nend\n'); + + await writeFile( + 'app/mix.exs', + 'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:shared, "~> 0.1"}]\n end\nend\n', + ); + await writeFile( + 'app/lib/app/main.ex', + 'defmodule App.Main do\n alias Shared.{Config, Logger}\nend\n', + ); + + const repos = { shared: 'shared', app: 'app' }; + const repoPaths = new Map([ + ['shared', path.join(tmpDir, 'shared')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(2); + const contracts = result.links.map((l) => l.contract).sort(); + expect(contracts).toEqual(['Shared.Config', 'Shared.Logger']); + }); + + it('handles direct module references (no alias)', async () => { + await writeFile( + 'auth/mix.exs', + 'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n'); + + await writeFile( + 'api/mix.exs', + 'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n', + ); + await writeFile( + 'api/lib/api/handler.ex', + 'defmodule Api.Handler do\n def verify do\n Auth.Token.verify()\n end\nend\n', + ); + + const repos = { auth: 'auth', api: 'api' }; + const repoPaths = new Map([ + ['auth', path.join(tmpDir, 'auth')], + ['api', path.join(tmpDir, 'api')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Auth.Token'); + }); + + it('handles underscore app names (my_app -> MyApp)', async () => { + await writeFile( + 'data-store/mix.exs', + 'defmodule DataStore.MixProject do\n use Mix.Project\n def project do\n [app: :data_store, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('data-store/lib/data_store/repo.ex', 'defmodule DataStore.Repo do\nend\n'); + + await writeFile( + 'web/mix.exs', + 'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:data_store, in_umbrella: true}]\n end\nend\n', + ); + await writeFile('web/lib/web/page.ex', 'defmodule Web.Page do\n alias DataStore.Repo\nend\n'); + + const repos = { store: 'data_store', web: 'web' }; + const repoPaths = new Map([ + ['store', path.join(tmpDir, 'data-store')], + ['web', path.join(tmpDir, 'web')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('DataStore.Repo'); + }); + + it('skips repos without mix.exs', async () => { + await writeFile('js-app/package.json', '{"name": "js-app"}'); + + const repos = { app: 'js-app' }; + const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + expect(result.discoveredApps.size).toBe(0); + }); + + it('deduplicates identical module refs from multiple files', async () => { + await writeFile( + 'lib/mix.exs', + 'defmodule Lib.MixProject do\n use Mix.Project\n def project do\n [app: :lib, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('lib/lib/lib/config.ex', 'defmodule Lib.Config do\nend\n'); + + await writeFile( + 'app/mix.exs', + 'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:lib, "~> 0.1"}]\n end\nend\n', + ); + await writeFile('app/lib/app/a.ex', 'defmodule App.A do\n alias Lib.Config\nend\n'); + await writeFile('app/lib/app/b.ex', 'defmodule App.B do\n alias Lib.Config\nend\n'); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + }); + + it('collapses nested submodules to top-level module contract', async () => { + await writeFile( + 'core/mix.exs', + 'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('core/lib/core/auth/token.ex', 'defmodule Core.Auth.Token do\nend\n'); + await writeFile('core/lib/core/auth/session.ex', 'defmodule Core.Auth.Session do\nend\n'); + + await writeFile( + 'web/mix.exs', + 'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n', + ); + await writeFile( + 'web/lib/web/ctrl.ex', + 'defmodule Web.Ctrl do\n alias Core.Auth.Token\n alias Core.Auth.Session\nend\n', + ); + + const repos = { core: 'core', web: 'web' }; + const repoPaths = new Map([ + ['core', path.join(tmpDir, 'core')], + ['web', path.join(tmpDir, 'web')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Core.Auth'); + }); + + it('does not produce false positives from module references in comments', async () => { + await writeFile( + 'auth/mix.exs', + 'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n'); + + await writeFile( + 'api/mix.exs', + 'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n', + ); + await writeFile( + 'api/lib/api/handler.ex', + 'defmodule Api.Handler do\n # See Auth.Token for details\n # Auth.Token.verify() is deprecated\n def handle, do: :ok\nend\n', + ); + + const repos = { auth: 'auth', api: 'api' }; + const repoPaths = new Map([ + ['auth', path.join(tmpDir, 'auth')], + ['api', path.join(tmpDir, 'api')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + }); + + it('handles git and path deps alongside umbrella deps', async () => { + await writeFile( + 'utils/mix.exs', + 'defmodule Utils.MixProject do\n use Mix.Project\n def project do\n [app: :utils, version: "0.1.0"]\n end\nend\n', + ); + await writeFile('utils/lib/utils/helper.ex', 'defmodule Utils.Helper do\nend\n'); + + await writeFile( + 'svc/mix.exs', + 'defmodule Svc.MixProject do\n use Mix.Project\n def project do\n [app: :svc, version: "0.1.0"]\n end\n defp deps do\n [{:utils, git: "https://github.com/org/utils.git"}]\n end\nend\n', + ); + await writeFile( + 'svc/lib/svc/worker.ex', + 'defmodule Svc.Worker do\n alias Utils.Helper\nend\n', + ); + + const repos = { utils: 'utils', svc: 'svc' }; + const repoPaths = new Map([ + ['utils', path.join(tmpDir, 'utils')], + ['svc', path.join(tmpDir, 'svc')], + ]); + + const result = await extractElixirWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('Utils.Helper'); + }); +}); diff --git a/gitnexus/test/unit/group/go-workspace-extractor.test.ts b/gitnexus/test/unit/group/go-workspace-extractor.test.ts new file mode 100644 index 000000000..a09e4314e --- /dev/null +++ b/gitnexus/test/unit/group/go-workspace-extractor.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { extractGoWorkspaceLinks } from '../../../src/core/group/extractors/go-workspace-extractor.js'; + +describe('GoWorkspaceExtractor', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-ws-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeFile(relPath: string, content: string) { + const absPath = path.join(tmpDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf-8'); + } + + it('discovers cross-module type usage via require', async () => { + await writeFile('models/go.mod', 'module github.com/org/models\n\ngo 1.21\n'); + await writeFile('models/schema.go', 'package models\n\ntype Schema struct {}\n'); + + await writeFile( + 'api/go.mod', + 'module github.com/org/api\n\ngo 1.21\n\nrequire github.com/org/models v0.1.0\n', + ); + await writeFile( + 'api/main.go', + 'package main\n\nimport "github.com/org/models"\n\nfunc main() {\n\tvar s models.Schema\n\t_ = s\n}\n', + ); + + const repos = { + 'libs/models': 'models', + 'services/api': 'api', + }; + const repoPaths = new Map([ + ['libs/models', path.join(tmpDir, 'models')], + ['services/api', path.join(tmpDir, 'api')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0]).toEqual({ + from: 'libs/models', + to: 'services/api', + type: 'custom', + contract: 'github.com/org/models::Schema', + role: 'provider', + }); + }); + + it('handles block require syntax', async () => { + await writeFile('auth/go.mod', 'module github.com/org/auth\n\ngo 1.21\n'); + await writeFile('auth/token.go', 'package auth\n\ntype Token struct {}\n'); + + await writeFile( + 'svc/go.mod', + 'module github.com/org/svc\n\ngo 1.21\n\nrequire (\n\tgithub.com/org/auth v1.0.0\n)\n', + ); + await writeFile( + 'svc/main.go', + 'package main\n\nimport (\n\t"github.com/org/auth"\n)\n\nfunc handle() auth.Token { return auth.Token{} }\n', + ); + + const repos = { auth: 'auth', svc: 'svc' }; + const repoPaths = new Map([ + ['auth', path.join(tmpDir, 'auth')], + ['svc', path.join(tmpDir, 'svc')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('github.com/org/auth::Token'); + }); + + it('handles subpackage imports (module/pkg)', async () => { + await writeFile('core/go.mod', 'module github.com/org/core\n\ngo 1.21\n'); + await writeFile('core/types/entity.go', 'package types\n\ntype Entity struct {}\n'); + + await writeFile( + 'app/go.mod', + 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/core v0.1.0\n', + ); + await writeFile( + 'app/main.go', + 'package main\n\nimport "github.com/org/core/types"\n\nvar e types.Entity\n', + ); + + const repos = { core: 'core', app: 'app' }; + const repoPaths = new Map([ + ['core', path.join(tmpDir, 'core')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('github.com/org/core::Entity'); + }); + + it('handles replace directive with local paths', async () => { + await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n'); + await writeFile('lib/config.go', 'package lib\n\ntype Config struct {}\n'); + + await writeFile( + 'app/go.mod', + 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.0.0\n\nreplace github.com/org/lib => ./lib\n', + ); + await writeFile( + 'app/main.go', + 'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('github.com/org/lib::Config'); + }); + + it('ignores unexported (lowercase) identifiers', async () => { + await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n'); + await writeFile('lib/util.go', 'package lib\n\nfunc helper() {}\ntype Config struct {}\n'); + + await writeFile( + 'app/go.mod', + 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n', + ); + await writeFile( + 'app/main.go', + 'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('github.com/org/lib::Config'); + }); + + it('does not produce links for aliased imports (V1 false-negative limitation)', async () => { + await writeFile('shared/go.mod', 'module github.com/org/shared\n\ngo 1.21\n'); + await writeFile('shared/config.go', 'package shared\n\ntype Config struct {}\n'); + + await writeFile( + 'app/go.mod', + 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/shared v0.1.0\n', + ); + await writeFile( + 'app/main.go', + 'package main\n\nimport cfg "github.com/org/shared"\n\nvar c cfg.Config\n', + ); + + const repos = { shared: 'shared', app: 'app' }; + const repoPaths = new Map([ + ['shared', path.join(tmpDir, 'shared')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + }); + + it('skips repos without go.mod', async () => { + await writeFile('js-app/package.json', '{"name": "js-app"}'); + + const repos = { app: 'js-app' }; + const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + expect(result.discoveredModules.size).toBe(0); + }); + + it('deduplicates identical type usage from multiple files', async () => { + await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n'); + await writeFile('lib/model.go', 'package lib\n\ntype Model struct {}\n'); + + await writeFile( + 'app/go.mod', + 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n', + ); + await writeFile('app/a.go', 'package main\n\nimport "github.com/org/lib"\n\nvar x lib.Model\n'); + await writeFile('app/b.go', 'package main\n\nimport "github.com/org/lib"\n\nvar y lib.Model\n'); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + }); + + it('discovers multiple types from the same module', async () => { + await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n'); + await writeFile( + 'lib/types.go', + 'package lib\n\ntype Request struct {}\ntype Response struct {}\n', + ); + + await writeFile( + 'app/go.mod', + 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n', + ); + await writeFile( + 'app/main.go', + 'package main\n\nimport "github.com/org/lib"\n\nfunc handle(r lib.Request) lib.Response { return lib.Response{} }\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractGoWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(2); + const contracts = result.links.map((l) => l.contract).sort(); + expect(contracts).toEqual(['github.com/org/lib::Request', 'github.com/org/lib::Response']); + }); +}); diff --git a/gitnexus/test/unit/group/java-workspace-extractor.test.ts b/gitnexus/test/unit/group/java-workspace-extractor.test.ts new file mode 100644 index 000000000..09233ab12 --- /dev/null +++ b/gitnexus/test/unit/group/java-workspace-extractor.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { extractJavaWorkspaceLinks } from '../../../src/core/group/extractors/java-workspace-extractor.js'; + +describe('JavaWorkspaceExtractor', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-ws-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeFile(relPath: string, content: string) { + const absPath = path.join(tmpDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf-8'); + } + + const pomTemplate = (g: string, a: string, deps: string[] = []) => { + const depXml = deps + .map((d) => { + const [gid, aid] = d.split(':'); + return `${gid}${aid}`; + }) + .join('\n'); + return `${g}${a}${depXml}`; + }; + + it('discovers cross-project imports via Maven pom.xml', async () => { + await writeFile('models/pom.xml', pomTemplate('com.acme', 'models')); + await writeFile( + 'models/src/main/java/com/acme/models/User.java', + 'package com.acme.models;\npublic class User {}\n', + ); + + await writeFile('api/pom.xml', pomTemplate('com.acme', 'api', ['com.acme:models'])); + await writeFile( + 'api/src/main/java/com/acme/api/UserService.java', + 'package com.acme.api;\nimport com.acme.models.User;\npublic class UserService {}\n', + ); + + const repos = { models: 'models', api: 'api' }; + const repoPaths = new Map([ + ['models', path.join(tmpDir, 'models')], + ['api', path.join(tmpDir, 'api')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0]).toEqual({ + from: 'models', + to: 'api', + type: 'custom', + contract: 'models::User', + role: 'provider', + }); + }); + + it('handles Gradle build files', async () => { + await writeFile('core/build.gradle.kts', 'group = "com.acme"\nversion = "1.0"\n'); + await writeFile( + 'core/src/main/java/com/acme/core/Config.java', + 'package com.acme.core;\npublic class Config {}\n', + ); + + await writeFile( + 'svc/build.gradle.kts', + 'group = "com.acme"\nversion = "1.0"\ndependencies {\n implementation("com.acme:core:1.0")\n}\n', + ); + await writeFile( + 'svc/src/main/java/com/acme/svc/App.java', + 'package com.acme.svc;\nimport com.acme.core.Config;\npublic class App {}\n', + ); + + const repos = { core: 'core', svc: 'svc' }; + const repoPaths = new Map([ + ['core', path.join(tmpDir, 'core')], + ['svc', path.join(tmpDir, 'svc')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('core::Config'); + }); + + it('handles Gradle project dependencies', async () => { + await writeFile('common/build.gradle', "group = 'com.org'\nversion = '1.0'\n"); + await writeFile( + 'common/src/main/java/com/org/common/Entity.java', + 'package com.org.common;\npublic class Entity {}\n', + ); + + await writeFile( + 'app/build.gradle', + "group = 'com.org'\nversion = '1.0'\ndependencies {\n implementation(project(':common'))\n}\n", + ); + await writeFile( + 'app/src/main/java/com/org/app/Main.java', + 'package com.org.app;\nimport com.org.common.Entity;\npublic class Main {}\n', + ); + + const repos = { common: 'common', app: 'app' }; + const repoPaths = new Map([ + ['common', path.join(tmpDir, 'common')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('common::Entity'); + }); + + it('handles static imports', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/java/com/acme/lib/Constants.java', + 'package com.acme.lib;\npublic class Constants {}\n', + ); + + await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib'])); + await writeFile( + 'app/src/main/java/com/acme/app/Main.java', + 'package com.acme.app;\nimport static com.acme.lib.Constants;\npublic class Main {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('lib::Constants'); + }); + + it('skips repos without Java manifest', async () => { + await writeFile('rs-app/Cargo.toml', '[package]\nname = "rapp"\n'); + + const repos = { app: 'rapp' }; + const repoPaths = new Map([['app', path.join(tmpDir, 'rs-app')]]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + expect(result.discoveredProjects.size).toBe(0); + }); + + it('deduplicates identical imports from multiple files', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/java/com/acme/lib/Token.java', + 'package com.acme.lib;\npublic class Token {}\n', + ); + + await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib'])); + await writeFile( + 'app/src/main/java/com/acme/app/A.java', + 'package com.acme.app;\nimport com.acme.lib.Token;\npublic class A {}\n', + ); + await writeFile( + 'app/src/main/java/com/acme/app/B.java', + 'package com.acme.app;\nimport com.acme.lib.Token;\npublic class B {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + }); + + it('discovers Kotlin file imports from Java projects', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/kotlin/com/acme/lib/Model.kt', + 'package com.acme.lib\ndata class Model(val id: Int)\n', + ); + + await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib'])); + await writeFile( + 'app/src/main/kotlin/com/acme/app/Main.kt', + 'package com.acme.app\nimport com.acme.lib.Model\nfun main() {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0].contract).toBe('lib::Model'); + }); + + it('discovers multiple types from the same dependency', async () => { + await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib')); + await writeFile( + 'lib/src/main/java/com/acme/lib/Request.java', + 'package com.acme.lib;\npublic class Request {}\n', + ); + await writeFile( + 'lib/src/main/java/com/acme/lib/Response.java', + 'package com.acme.lib;\npublic class Response {}\n', + ); + + await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib'])); + await writeFile( + 'app/src/main/java/com/acme/app/Handler.java', + 'package com.acme.app;\nimport com.acme.lib.Request;\nimport com.acme.lib.Response;\npublic class Handler {}\n', + ); + + const repos = { lib: 'lib', app: 'app' }; + const repoPaths = new Map([ + ['lib', path.join(tmpDir, 'lib')], + ['app', path.join(tmpDir, 'app')], + ]); + + const result = await extractJavaWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(2); + const contracts = result.links.map((l) => l.contract).sort(); + expect(contracts).toEqual(['lib::Request', 'lib::Response']); + }); +}); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index 997445f2e..bb4e203fa 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -596,7 +596,7 @@ describe('ManifestExtractor', () => { [ 'engine/thales', async (_cypher, params) => { - if (params?.contract === 'Expression') { + if (params?.symbolName === 'Expression') { return [ { uid: 'uid-expression-struct', @@ -611,7 +611,7 @@ describe('ManifestExtractor', () => { [ 'parser/mathlex', async (_cypher, params) => { - if (params?.contract === 'Expression') { + if (params?.symbolName === 'Expression') { return [ { uid: 'uid-expression-enum', @@ -640,6 +640,42 @@ describe('ManifestExtractor', () => { expect(result.crossLinks[0].matchType).toBe('manifest'); }); + it('custom contract with qualified name (provider::Symbol) strips prefix before graph query', async () => { + const links: GroupManifestLink[] = [ + { + from: 'parser/mathlex', + to: 'engine/thales', + type: 'custom', + contract: 'mathlex::Expression', + role: 'provider', + }, + ]; + + let capturedParams: Record | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'parser/mathlex', + async (_cypher, params) => { + capturedParams = params; + if (params?.symbolName === 'Expression') { + return [{ uid: 'uid-expr', name: 'Expression', filePath: 'src/ast.rs' }]; + } + return []; + }, + ], + ['engine/thales', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + const provider = result.contracts.find((c) => c.role === 'provider'); + + expect(capturedParams?.symbolName).toBe('Expression'); + expect(provider?.symbolUid).toBe('uid-expr'); + }); + it('falls back to synthetic uid when custom symbol not found in graph', async () => { const links: GroupManifestLink[] = [ { @@ -714,7 +750,7 @@ describe('ManifestExtractor', () => { [ 'parser/mathlex', async (_cypher, params) => { - if (params?.contract === 'Token') { + if (params?.symbolName === 'Token') { return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }]; } return []; @@ -723,7 +759,7 @@ describe('ManifestExtractor', () => { [ 'engine/thales', async (_cypher, params) => { - if (params?.contract === 'Token') { + if (params?.symbolName === 'Token') { return [{ uid: 'uid-token-consumer', name: 'Token', filePath: 'src/lexer.rs' }]; } return []; diff --git a/gitnexus/test/unit/group/node-workspace-extractor.test.ts b/gitnexus/test/unit/group/node-workspace-extractor.test.ts new file mode 100644 index 000000000..163b0be37 --- /dev/null +++ b/gitnexus/test/unit/group/node-workspace-extractor.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { extractNodeWorkspaceLinks } from '../../../src/core/group/extractors/node-workspace-extractor.js'; + +describe('NodeWorkspaceExtractor', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-node-ws-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeFile(relPath: string, content: string) { + const absPath = path.join(tmpDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, content, 'utf-8'); + } + + it('discovers cross-package ES imports', async () => { + await writeFile( + 'pkg-a/package.json', + JSON.stringify({ name: '@myorg/shared', version: '1.0.0' }), + ); + await writeFile('pkg-a/src/index.ts', 'export class Config {}\nexport class Logger {}\n'); + + await writeFile( + 'pkg-b/package.json', + JSON.stringify({ + name: '@myorg/api', + version: '1.0.0', + dependencies: { '@myorg/shared': 'workspace:*' }, + }), + ); + await writeFile( + 'pkg-b/src/server.ts', + "import { Config } from '@myorg/shared';\nconst c = new Config();\n", + ); + + const repos = { + 'libs/shared': '@myorg/shared', + 'services/api': '@myorg/api', + }; + const repoPaths = new Map([ + ['libs/shared', path.join(tmpDir, 'pkg-a')], + ['services/api', path.join(tmpDir, 'pkg-b')], + ]); + + const result = await extractNodeWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(1); + expect(result.links[0]).toEqual({ + from: 'libs/shared', + to: 'services/api', + type: 'custom', + contract: '@myorg/shared::Config', + role: 'provider', + }); + }); + + it('handles default imports (PascalCase)', async () => { + await writeFile( + 'ui-lib/package.json', + JSON.stringify({ name: 'ui-components', version: '1.0.0' }), + ); + await writeFile('ui-lib/src/index.ts', 'export default class Button {}\n'); + + await writeFile( + 'app/package.json', + JSON.stringify({ + name: 'web-app', + version: '1.0.0', + dependencies: { 'ui-components': '^1.0.0' }, + }), + ); + await writeFile( + 'app/src/page.tsx', + "import Button from 'ui-components';\nexport default function Page() { return