diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 578672e15..553f5f0ba 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -190,6 +190,7 @@ const LBUG_NATIVE = [ 'test/integration/lbug-incremental-search.test.ts', // Retained identities and reader metadata must agree on every native platform. 'test/integration/incremental-method-integrity.test.ts', + 'test/integration/go-incremental-method-owners.test.ts', 'test/integration/query-symbol-integrity.test.ts', 'test/integration/mcp-index-generation.test.ts', 'test/integration/fts-repair-warm-session.test.ts', diff --git a/gitnexus/src/core/incremental/resolution-groups.ts b/gitnexus/src/core/incremental/resolution-groups.ts new file mode 100644 index 000000000..d2699bb11 --- /dev/null +++ b/gitnexus/src/core/incremental/resolution-groups.ts @@ -0,0 +1,31 @@ +import { getProviderForFile } from '../ingestion/languages/index.js'; + +/** + * Collect current files whose resolution may change with a sibling. Provider + * keys are language-local, and changed paths may include deleted files. + * Two linear passes avoid rescanning the repository for every changed file. + */ +export function collectResolutionGroupFiles( + changedFiles: readonly string[], + currentFiles: readonly string[], +): Set { + const changedGroups = new Set(); + for (const filePath of changedFiles) { + const provider = getProviderForFile(filePath); + if (!provider?.incrementalResolutionGroup) continue; + const group = provider.incrementalResolutionGroup(filePath); + if (group !== undefined) changedGroups.add(`${provider.id}\0${group}`); + } + + const affected = new Set(); + if (changedGroups.size === 0) return affected; + for (const filePath of currentFiles) { + const provider = getProviderForFile(filePath); + if (!provider?.incrementalResolutionGroup) continue; + const group = provider.incrementalResolutionGroup(filePath); + if (group !== undefined && changedGroups.has(`${provider.id}\0${group}`)) { + affected.add(filePath); + } + } + return affected; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 813d47520..5610c438a 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -241,6 +241,15 @@ interface LanguageProviderConfig { /** File extensions that map to this language (e.g., ['.ts', '.tsx']) */ readonly extensions: readonly string[]; + /** + * Files with the same provider-local key must be rewritten together when + * any member changes, is added, or is deleted. Resolution can change edges + * between untouched members even without an import or current graph edge. + * Derive the key from the path alone: deleted files have no current source. + * This only widens incremental invalidation, not semantic resolution scope. + */ + readonly incrementalResolutionGroup?: (filePath: string) => string | undefined; + /** Entry-point function name patterns specific to this language. * Merged with universal patterns at runtime for process detection scoring. * Default: [] (only universal patterns apply). */ @@ -361,6 +370,13 @@ interface LanguageProviderConfig { * Default: undefined (no remapping). */ readonly resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null; + /** Name an owner declared outside this member's lexical container. + * Consulted for each ancestor during the enclosing-owner walk. The name + * qualifies member identities, but carries no graph id: ownership is + * resolved by `ScopeResolver.populateWorkspaceOwners` after all files load. + * Default: undefined (owners are derived from lexical containers). */ + readonly resolveDetachedOwnerName?: (node: SyntaxNode) => string | undefined; + /** * The type a whole FILE declares, when the language makes the file itself * a type (Zig: a `.zig` file with top-level fields is a struct whose name diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 91b973146..3f2502a32 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -29,6 +29,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { goVariableConfig } from '../variable-extractors/configs/go.js'; import { extractGoGinEchoRoutes } from '../route-extractors/go-gin-echo.js'; import { resolveGoRouteHandler } from './go/route-handler.js'; +import { goPackageDir } from './go/package-clause.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { goCallConfig } from '../call-extractors/configs/go.js'; import { @@ -95,6 +96,9 @@ export const goProvider = defineLanguage({ rename: goRenameProvider, id: SupportedLanguages.Go, extensions: ['.go'], + // Package members resolve without imports. Include internal/external test + // packages conservatively for invalidation, including when a file is gone. + incrementalResolutionGroup: goPackageDir, entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/], astFrameworkPatterns: [ { @@ -141,6 +145,8 @@ export const goProvider = defineLanguage({ callExtractor: createCallExtractor(goCallConfig), fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), + resolveDetachedOwnerName: (node) => + node.type === 'method_declaration' ? goMethodConfig.extractOwnerName?.(node) : undefined, variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), // ── godoc (`//` leading comments) → description (issue #2270). Build/tool diff --git a/gitnexus/src/core/ingestion/languages/go/build-constraints.ts b/gitnexus/src/core/ingestion/languages/go/build-constraints.ts new file mode 100644 index 000000000..293cc1def --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/build-constraints.ts @@ -0,0 +1,227 @@ +// Go's filename rules include historical targets and the android/linux, +// ios/darwin and illumos/solaris aliases. Keep these tables aligned with +// https://go.dev/src/internal/syslist/syslist.go and go/build.matchTag. +const GOOS = new Set( + 'aix android darwin dragonfly freebsd hurd illumos ios js linux nacl netbsd openbsd plan9 solaris wasip1 windows zos'.split( + ' ', + ), +); +const GOARCH = new Set( + '386 amd64 amd64p32 arm armbe arm64 arm64be loong64 mips mipsle mips64 mips64le mips64p32 mips64p32le ppc ppc64 ppc64le riscv riscv64 s390 s390x sparc sparc64 wasm'.split( + ' ', + ), +); +const UNIX = new Set( + 'aix android darwin dragonfly freebsd hurd illumos ios linux netbsd openbsd solaris'.split(' '), +); +const OS_ALIAS: Readonly> = { + android: 'linux', + ios: 'darwin', + illumos: 'solaris', +}; + +type Term = ReadonlyMap; +/** OR of AND terms; null means unknown, [] means unsatisfiable. */ +export type GoBuildConstraint = readonly Term[] | null; +type Expr = string | { op: 'not'; value: Expr } | { op: 'and' | 'or'; left: Expr; right: Expr }; +const MAX_TERMS = 64; +const MAX_TOKENS = 128; +const MAX_HEADER = 16384; +const TAG = /^[\p{L}\p{Nd}_.]+$/u; + +function parseExpression(text: string): Expr | undefined { + // Go's expression lexer skips only ASCII spaces/tabs, not all Unicode whitespace. + const tokens = text.match(/&&|\|\||[!()]|[\p{L}\p{Nd}_.]+|[^ \t]/gu) ?? []; + if (tokens.length === 0 || tokens.length > MAX_TOKENS) return undefined; + let i = 0; + const atom = (): Expr => { + const token = tokens[i++]; + if (token === '!') { + if (tokens[i] === '!') throw new Error('double negation not allowed'); + return { op: 'not', value: atom() }; + } + if (token === '(') { + const value = or(); + if (tokens[i++] !== ')') throw new Error('unclosed build constraint'); + return value; + } + if (!token || !TAG.test(token)) throw new Error('invalid build tag'); + return token; + }; + const and = (): Expr => { + let left = atom(); + while (tokens[i] === '&&') { + i++; + left = { op: 'and', left, right: atom() }; + } + return left; + }; + const or = (): Expr => { + let left = and(); + while (tokens[i] === '||') { + i++; + left = { op: 'or', left, right: and() }; + } + return left; + }; + try { + const result = or(); + return i === tokens.length ? result : undefined; + } catch { + return undefined; + } +} + +function mergeTerms(a: Term, b: Term): Term | undefined { + const merged = new Map(a); + for (const [tag, value] of b) { + if (merged.has(tag) && merged.get(tag) !== value) return undefined; + merged.set(tag, value); + } + return merged; +} + +function conjunction(a: GoBuildConstraint, b: GoBuildConstraint): GoBuildConstraint { + if (a === null || b === null || a.length * b.length > MAX_TERMS) return null; + const terms: Term[] = []; + for (const left of a) + for (const right of b) { + const merged = mergeTerms(left, right); + if (merged) terms.push(merged); + } + return terms; +} + +function toTerms(expr: Expr, negated = false): GoBuildConstraint { + if (typeof expr === 'string') return [new Map([[expr, !negated]])]; + if (expr.op === 'not') return toTerms(expr.value, !negated); + const left = toTerms(expr.left, negated); + const right = toTerms(expr.right, negated); + if ((expr.op === 'and') !== negated) return conjunction(left, right); + if (left === null || right === null || left.length + right.length > MAX_TERMS) return null; + return [...left, ...right]; +} + +/** Read only leading comments; a directive quoted inside a block is not active. */ +function headerExpression(source: string): string | null { + const header = source.slice(0, MAX_HEADER + 1); + let modern: string | undefined; + const legacy: string[] = []; + let legacyEnd = 0; + let ended = false; + let inBlock = false; + let offset = 0; + // Bounded header scan, without splitting/copying an entire source file. + while (offset < header.length && offset < MAX_HEADER) { + const newline = header.indexOf('\n', offset); + const end = newline === -1 ? header.length : newline; + if (end > MAX_HEADER) return null; + let line = header.slice(offset, end).trim(); + offset = end + 1; + if (!line && !ended) legacyEnd = legacy.length; + if (!line.startsWith('//')) ended = ended || line.length > 0; + if (!inBlock && /^\/\/go:build(?:\s|$)/.test(line)) { + if (modern !== undefined) return null; + modern = line.slice('//go:build'.length).trim(); + if (!modern) return null; + } + if (!ended && /^\/\/\s*\+build(?:\s|$)/.test(line)) { + legacy.push(line.replace(/^\/\/\s*\+build\s*/, '')); + } + while (line) { + if (inBlock) { + const close = line.indexOf('*/'); + if (close === -1) break; + inBlock = false; + line = line.slice(close + 2).trim(); + } else if (line.startsWith('/*')) { + inBlock = true; + line = line.slice(2); + } else if (line.startsWith('//')) { + break; + } else { + // Modern syntax takes precedence over legacy +build lines. + if (modern !== undefined) return modern; + const lines = legacy.slice(0, legacyEnd); + if ( + lines.some( + (text) => + !text || text.split(/[\s,]+/).some((tag) => !/^!?[\p{L}\p{Nd}_.]+$/u.test(tag)), + ) + ) + return null; + return lines + .map( + (text) => + `(${text + .split(/\s+/) + .map((term) => `(${term.split(',').join(' && ')})`) + .join(' || ')})`, + ) + .join(' && '); + } + } + } + return null; +} + +/** + * File eligibility for repository-wide structural analysis, not a host build. + * Unknown/malformed or overly complex constraints remain possible, so they + * cannot be used to prove that a competing owner is absent. Arbitrary tags + * stay symbolic; OS/arch tags describe normal target configurations (not + * user -tags overrides of reserved platform names). Feature/release tag + * implications are deliberately over-approximated, never guessed. + */ +export function goFileBuildConstraint( + filePath: string, + source: string | undefined, +): GoBuildConstraint { + // go/build.goodOSArchFile checks the stem before the first dot. + const basename = (filePath.replace(/\\/g, '/').split('/').pop() ?? '').split('.')[0]; + const parts = basename.split('_'); + if (parts.at(-1) === 'test') parts.pop(); + const filename = new Map(); + if (parts.length >= 2) { + const last = parts.at(-1) ?? ''; + const previous = parts.at(-2) ?? ''; + if (GOOS.has(last) || GOARCH.has(last)) filename.set(last, true); + if (parts.length >= 3 && GOARCH.has(last) && GOOS.has(previous)) filename.set(previous, true); + } + if (source === undefined) return null; + const text = headerExpression(source); + if (text === null) return null; + if (!text) return [filename]; + const expr = parseExpression(text); + return expr === undefined ? null : conjunction([filename], toTerms(expr)); +} + +function possibleTarget(term: Term): boolean { + // The sentinel preserves possibilities for future targets. Only recognized + // platform tags constrain an axis; unknown tags remain independent booleans. + let operatingSystems = [...GOOS, '']; + let architectures = [...GOARCH, '']; + for (const [tag, value] of term) { + if (GOOS.has(tag) || tag === 'unix') { + operatingSystems = operatingSystems.filter( + (os) => (os === tag || OS_ALIAS[os] === tag || (tag === 'unix' && UNIX.has(os))) === value, + ); + } else if (GOARCH.has(tag)) { + architectures = architectures.filter((arch) => (arch === tag) === value); + } + } + return operatingSystems.length > 0 && architectures.length > 0; +} + +export function goBuildConstraintsMayOverlap(a: GoBuildConstraint, b: GoBuildConstraint): boolean { + if (a === null || b === null) { + const known = a ?? b; + return known === null || known.some(possibleTarget); + } + for (const left of a) + for (const right of b) { + const merged = mergeTerms(left, right); + if (merged && possibleTarget(merged)) return true; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts index d32e4d79b..0fe897e7d 100644 --- a/gitnexus/src/core/ingestion/languages/go/method-owners.ts +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -4,6 +4,7 @@ import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/s import { goPackageDir, inferGoPackageName } from './package-clause.js'; import { stampGoInterfaceTypeParameters } from './generic-type-parameters.js'; +import { goFileBuildConstraint, goBuildConstraintsMayOverlap } from './build-constraints.js'; /** Bound on the sample of no-package-clause paths named in the warning. */ const SKIPPED_SAMPLE_CAP = 5; @@ -71,22 +72,48 @@ export function populateGoWorkspaceOwners( } for (const bucket of filesByPackage.values()) { - populateGoOwnersInPackage(bucket); + populateGoOwnersInPackage(bucket, ctx.fileContents); } } -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(); +function populateGoOwnersInPackage( + parsedFiles: readonly ParsedFile[], + fileContents?: ReadonlyMap, +): void { + // Receiver base types must be declared at package scope. Struct defs live + // in their own Class scopes; local types below a Function or Block cannot + // own top-level methods or make a package-level candidate ambiguous. + // Keep candidates until each method's build constraints are known. Two + // declarations in disjoint build variants are not competing owners. + type Owner = { nodeId: string; qualifiedName: string; filePath: string }; + const structByQualifiedName = new Map>(); + const constraints = new Map( + parsedFiles.map((parsed) => [ + parsed.filePath, + goFileBuildConstraint(parsed.filePath, fileContents?.get(parsed.filePath)), + ]), + ); for (const parsed of parsedFiles) { + const moduleIds = new Set( + parsed.scopes.filter((scope) => scope.kind === 'Module').map((scope) => scope.id), + ); for (const scope of parsed.scopes) { + if ( + scope.kind !== 'Module' && + !(scope.kind === 'Class' && scope.parent !== null && moduleIds.has(scope.parent)) + ) { + continue; + } for (const def of scope.ownedDefs) { if (isClassLike(def.type) && def.qualifiedName) { - structByQualifiedName.set(def.qualifiedName, { + const candidates = + structByQualifiedName.get(def.qualifiedName) ?? new Map(); + candidates.set(def.nodeId, { nodeId: def.nodeId, qualifiedName: def.qualifiedName, + filePath: parsed.filePath, }); + structByQualifiedName.set(def.qualifiedName, candidates); } } } @@ -95,13 +122,13 @@ function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void { // 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. + const candidatesByReceiver = new Map(); if (structByQualifiedName.size > 0) { for (const parsed of parsedFiles) { + const compatibleFiles = new Map(); for (const scope of parsed.scopes) { if (scope.kind !== 'Function') continue; - const methodDefs = scope.ownedDefs.filter( - (d) => d.type === 'Method' && d.ownerId === undefined, - ); + const methodDefs = scope.ownedDefs.filter((d) => d.type === 'Method'); if (methodDefs.length === 0) continue; // Find the self typeBinding in this Function scope. @@ -117,16 +144,42 @@ function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void { if (receiverType === undefined) continue; receiverType = receiverType.replace(/^\*+/, '').trim(); - let owner = structByQualifiedName.get(receiverType); - if (owner === undefined) { - for (const [qname, candidate] of structByQualifiedName) { - if (qname.endsWith('.' + receiverType)) { - owner = candidate; - break; - } - } + // The per-file pass may have found a local declaration before the + // workspace revealed a duplicate. Recompute receiver ownership from + // the complete package instead of retaining that premature choice. + for (const def of methodDefs) { + delete (def as { ownerId?: string }).ownerId; + delete (def as { goReceiverKind?: 'value' | 'pointer' }).goReceiverKind; } - if (owner !== undefined) { + + let candidates = candidatesByReceiver.get(receiverType); + if (candidates === undefined) { + const exact = structByQualifiedName.get(receiverType); + candidates = exact + ? [...exact.values()] + : [...structByQualifiedName] + .filter(([qname]) => qname.endsWith('.' + receiverType)) + .flatMap(([, defs]) => [...defs.values()]); + candidatesByReceiver.set(receiverType, candidates); + } + let owner: Owner | null | undefined; + for (const candidate of candidates) { + let compatible = compatibleFiles.get(candidate.filePath); + if (compatible === undefined) { + compatible = goBuildConstraintsMayOverlap( + constraints.get(parsed.filePath) ?? null, + constraints.get(candidate.filePath) ?? null, + ); + compatibleFiles.set(candidate.filePath, compatible); + } + if (!compatible) continue; + if (owner !== undefined && owner.nodeId !== candidate.nodeId) { + owner = null; + break; + } + owner = candidate; + } + if (owner !== undefined && owner !== null) { for (const def of methodDefs) { (def as { ownerId?: string }).ownerId = owner.nodeId; (def as { goReceiverKind?: 'value' | 'pointer' }).goReceiverKind = receiverKind; diff --git a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts index a2c7542f8..48418c8b6 100644 --- a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts @@ -10,12 +10,16 @@ export function synthesizeGoReceiverBinding(fnNode: SyntaxNode): CaptureMatch | if (param === undefined) return null; const nameNode = param.childForFieldName('name'); const typeNode = param.childForFieldName('type'); - if (nameNode === null || typeNode === null) return null; + if (typeNode === null) return null; const typeName = typeNode.text; + // Unnamed receivers still declare method ownership. Keep their self type + // fact under a key that cannot be a Go identifier, so it never creates a + // usable receiver variable. Type bindings do not register lexical symbols. + const boundName = nameNode?.text ?? ''; return { - '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, nameNode.text), - '@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text), + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, boundName), + '@type-binding.name': syntheticCapture('@type-binding.name', nameNode ?? param, boundName), '@type-binding.type': syntheticCapture('@type-binding.type', typeNode, typeName), }; } diff --git a/gitnexus/src/core/ingestion/languages/go/route-handler.ts b/gitnexus/src/core/ingestion/languages/go/route-handler.ts index 086698dac..538ec0839 100644 --- a/gitnexus/src/core/ingestion/languages/go/route-handler.ts +++ b/gitnexus/src/core/ingestion/languages/go/route-handler.ts @@ -1,5 +1,4 @@ import type { SymbolDefinition } from 'gitnexus-shared'; -import { generateId } from '../../../../lib/utils.js'; import type { RouteHandlerResolutionHookContext } from '../../language-provider.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ExtractedDecoratorRoute } from '../../workers/parse-worker.js'; @@ -15,11 +14,9 @@ import { goPackageDir } from './package-clause.js'; * with the router and the handlers in different packages, a same-named method * in the router's own directory belongs to an unrelated type. * - * Runs at the end of the parse phase, before scope resolution, so a Method's - * `ownerId` is still the worker's `Struct::` id — keyed on - * the METHOD's file, which is not the struct's node id when the struct is - * declared in another file of the package. Ownership is therefore matched on - * either id. + * Runs before workspace ownership resolution. Deferred Method symbols retain + * their receiver-qualified names, so handlers can be matched without inventing + * an owner id from the method's file. */ export function resolveGoRouteHandler( route: ExtractedDecoratorRoute, @@ -109,12 +106,14 @@ class GoRouteHandlerIndex { let index = this.methods.get(member); if (!index) { index = new Map(); - for (const def of this.model.methods.lookupMethodByName(member)) { - if (def.filePath.endsWith('_test.go') || def.ownerId === undefined) continue; + // Ownerless methods are retained in the callable index until semantic + // reconciliation registers the resolved owner in MethodRegistry. + for (const def of this.model.symbols.lookupCallableByName(member)) { + if (def.type !== 'Method' || def.filePath.endsWith('_test.go')) continue; const packageDir = goPackageDir(def.filePath); - const prefix = generateId('Struct', `${def.filePath}:`); - if (def.ownerId.startsWith(prefix)) { - append(index, bucketKey(packageDir, def.ownerId.slice(prefix.length)), def); + const suffix = `.${member}`; + if (def.qualifiedName?.endsWith(suffix)) { + append(index, bucketKey(packageDir, def.qualifiedName.slice(0, -suffix.length)), def); } } this.methods.set(member, index); diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index ec9d083b6..4fa6494d2 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -19,6 +19,7 @@ import { goMapValueType } from './interpret.js'; import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; import { goIsNamespaceMemberVisible } from './namespace-member-visibility.js'; import { resolveGoImportBinding } from './import-binding.js'; +import { emitMethodOwnerEdges } from '../../scope-resolution/graph-bridge/ownership.js'; /** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted — * a literal inside the hook would mint a fresh RegExp per folded subscript. */ @@ -83,6 +84,7 @@ export const goScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => populateGoOwners(parsed), populateWorkspaceOwners: (parsedFiles, ctx) => populateGoWorkspaceOwners(parsedFiles, ctx), + emitPostResolutionEdges: emitMethodOwnerEdges, isSuperReceiver: () => false, diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ownership.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ownership.ts new file mode 100644 index 000000000..2e1322f79 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ownership.ts @@ -0,0 +1,52 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; +import { resolveDefGraphId } from './ids.js'; +import type { GraphNodeLookup } from './node-lookup.js'; + +/** + * Project resolved method ownership into the graph. Providers opt in through + * their post-resolution hook when owners cannot be known during file parsing. + * Both endpoints must be translated: semantic definition ids are not graph ids. + */ +export function emitMethodOwnerEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): void { + const ownerIds = new Set(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type === 'Method' && def.ownerId !== undefined) ownerIds.add(def.ownerId); + } + } + + const ownerGraphIds = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!ownerIds.has(def.nodeId)) continue; + const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (graphId !== undefined && graph.getNode(graphId) !== undefined) { + ownerGraphIds.set(def.nodeId, graphId); + } + } + } + + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type !== 'Method' || def.ownerId === undefined) continue; + const sourceId = ownerGraphIds.get(def.ownerId); + if (sourceId === undefined) continue; + const targetId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (targetId === undefined || graph.getNode(targetId) === undefined) continue; + graph.addRelationship({ + id: generateId('HAS_METHOD', `${sourceId}->${targetId}`), + sourceId, + targetId, + type: 'HAS_METHOD', + confidence: 1, + reason: '', + }); + } + } +} diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index f78ba4f64..9b051f223 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -650,9 +650,9 @@ export function getLabelFromCaptures( return 'CodeElement'; } -/** Enclosing class info: both the generated node ID and the bare class name. */ +/** Enclosing owner name, and its graph id when the declaration is local. */ export interface EnclosingClassInfo { - classId: string; // e.g. "Class:animal.dart:Animal" + classId?: string; // e.g. "Class:animal.dart:Animal"; absent for a detached owner className: string; // e.g. "Animal" /** * The owner node id keyed by the enclosing type's FULLY-QUALIFIED path @@ -666,7 +666,8 @@ export interface EnclosingClassInfo { } /** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name. - * For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). + * Detached owners retain their name for member identity; the workspace + * resolver supplies ownership once the declaration is known. * * @param resolveEnclosingOwner Optional language-specific hook for container remapping. * When provided and a CLASS_CONTAINER_TYPES node is found, this hook is called: @@ -937,6 +938,7 @@ export const findEnclosingClassInfo = ( container: SyntaxNode, filePath: string, ) => { readonly name: string; readonly label: NodeLabel } | null, + resolveDetachedOwnerName?: LanguageProvider['resolveDetachedOwnerName'], ): EnclosingClassInfo | null => { let current = node.parent; let iterations = 0; @@ -949,27 +951,8 @@ export const findEnclosingClassInfo = ( // Bail out rather than hang ingestion. return null; } - // Go: method_declaration has a receiver parameter with the struct type - if (current.type === 'method_declaration') { - const receiver = current.childForFieldName?.('receiver'); - if (receiver) { - const paramDecl = receiver.namedChildren?.find?.( - (c: SyntaxNode) => c.type === 'parameter_declaration', - ); - if (paramDecl) { - const typeNode = paramDecl.childForFieldName?.('type'); - if (typeNode) { - const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode; - if (inner && (inner.type === 'type_identifier' || inner.type === 'identifier')) { - return { - classId: generateId('Struct', `${filePath}:${inner.text}`), - className: inner.text, - }; - } - } - } - } - } + const detachedOwnerName = resolveDetachedOwnerName?.(current); + if (detachedOwnerName !== undefined) return { className: detachedOwnerName }; // Go: the `type_spec` IS the declared type (`type User struct { ... }`, and // one per member of a grouped `type ( A struct{…}; B struct{…} )` block). // diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 5ccc71135..3c61f88db 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1001,16 +1001,7 @@ const callableOwnQualifiedName = ( const prefix = enclosingCallablePrefix(fnNode, filePath, provider); const classInfo = - prefix === undefined - ? cachedFindEnclosingClassInfo( - fnNode, - filePath, - provider.resolveEnclosingOwner, - undefined, - provider.resolveFileTypeOwner, - provider.resolveContainerTypeOwner, - ) - : null; + prefix === undefined ? cachedFindEnclosingClassInfo(fnNode, filePath, provider) : null; const objectOwner = prefix === undefined && classInfo === null && shouldObjectOwnerQualifyCallable(finalLabel) ? findObjectLiteralBindingInfo(fnNode, filePath, { includeOwnerName: true })?.ownerName @@ -1063,14 +1054,7 @@ const findEnclosingFunctionId = ( if (override !== null) finalLabel = override; } // Qualify with enclosing class to match definition-phase node IDs - const classInfo = cachedFindEnclosingClassInfo( - current, - filePath, - provider.resolveEnclosingOwner, - undefined, - provider.resolveFileTypeOwner, - provider.resolveContainerTypeOwner, - ); + const classInfo = cachedFindEnclosingClassInfo(current, filePath, provider); const encLang = getLanguageFromFilename(filePath); const standaloneMethodInfo = (finalLabel === 'Method' || finalLabel === 'Constructor') && @@ -1174,10 +1158,7 @@ const findEnclosingFunctionId = ( const classInfo = cachedFindEnclosingClassInfo( current.previousSibling ?? current, filePath, - provider.resolveEnclosingOwner, - undefined, - provider.resolveFileTypeOwner, - provider.resolveContainerTypeOwner, + provider, ); // Same nesting rule as the generic branch above (#2699). Anchored on // `sigNode`-equivalent (`current.previousSibling ?? current`) so Dart, @@ -1240,10 +1221,8 @@ const findEnclosingFunctionId = ( const cachedFindEnclosingClassInfo = ( node: SyntaxNode, filePath: string, - resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, + provider: LanguageProvider, getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null, - resolveFileTypeOwner?: LanguageProvider['resolveFileTypeOwner'], - resolveContainerTypeOwner?: LanguageProvider['resolveContainerTypeOwner'], ): EnclosingClassInfo | null => { const cached = classIdCache.get(node); if (cached !== undefined) return cached; @@ -1251,10 +1230,11 @@ const cachedFindEnclosingClassInfo = ( const result = findEnclosingClassInfo( node, filePath, - resolveEnclosingOwner, + provider.resolveEnclosingOwner, getQualifiedOwnerName, - resolveFileTypeOwner, - resolveContainerTypeOwner, + provider.resolveFileTypeOwner, + provider.resolveContainerTypeOwner, + provider.resolveDetachedOwnerName, ); classIdCache.set(node, result); return result; @@ -2241,10 +2221,8 @@ const processFileGroup = ( const propEnclosingInfo = cachedFindEnclosingClassInfo( captureMap['call'], file.path, - provider.resolveEnclosingOwner, + provider, propGetQualifiedOwnerName, - provider.resolveFileTypeOwner, - provider.resolveContainerTypeOwner, ); const propEnclosingClassId = propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null; @@ -2663,10 +2641,8 @@ const processFileGroup = ( ? cachedFindEnclosingClassInfo( nameNode || definitionNode, file.path, - provider.resolveEnclosingOwner, + provider, getQualifiedOwnerName, - provider.resolveFileTypeOwner, - provider.resolveContainerTypeOwner, ) : null; const enclosingClassId = @@ -3217,7 +3193,11 @@ const processFileGroup = ( name: nodeName, nodeId, type: nodeLabel, - ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), + ...(qualifiedTypeName !== undefined + ? { qualifiedName: qualifiedTypeName } + : enclosingClassInfo && enclosingClassId === null + ? { qualifiedName } + : {}), parameterCount: methodProps.parameterCount as number | undefined, requiredParameterCount: methodProps.requiredParameterCount as number | undefined, parameterTypes: methodProps.parameterTypes as string[] | undefined, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 7a1fcf895..dd48aeb9b 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -230,6 +230,7 @@ import { type PersistedSpringConfigConsumerRow, } from './incremental/spring-config-drift.js'; import { shadowCandidatesFor } from './incremental/shadow-candidates.js'; +import { collectResolutionGroupFiles } from './incremental/resolution-groups.js'; import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js'; import { ftsTablesAmong, @@ -3416,6 +3417,21 @@ async function runFullAnalysisInner( // in incremental/escalation-gate.ts (pure predicate, boundary-tested). const writableFiles = new Set(hashDiff.toWrite); const directlyChangedCount = writableFiles.size; + // Resolution groups can change edges between unchanged files without an + // import or a new-graph edge touching the changed/deleted member. Expand + // before the importer walk so dependents of these siblings refresh too. + for (const filePath of collectResolutionGroupFiles( + [...hashDiff.toWrite, ...hashDiff.deleted], + allFilePaths, + )) { + writableFiles.add(filePath); + } + const resolutionGroupExpansion = writableFiles.size - directlyChangedCount; + if (resolutionGroupExpansion > 0) { + log( + `Incremental: +${resolutionGroupExpansion} resolution sibling(s) added to writable set`, + ); + } const dirtyStartedAt = existingMeta!.incrementalInProgress?.startedAt ?? Date.now(); // Dropped-chunk observability (tri-review 4669518496 P2-5): counts // importer-BFS chunks whose IMPORTS query failed across ALL depths @@ -3481,7 +3497,7 @@ async function runFullAnalysisInner( // used to cost ~700 sequential lock-taking round-trips (~5.6s). The // closure is identical: importers already in writableFiles are not // re-frontiered, exactly like the per-file loop's membership check. - let frontier: string[] = [...hashDiff.toWrite, ...hashDiff.deleted, ...shadowSeed]; + let frontier: string[] = [...writableFiles, ...hashDiff.deleted, ...shadowSeed]; for (let depth = 0; depth < MAX_IMPORTER_BFS_DEPTH && frontier.length > 0; depth++) { const importers = await queryImportersBatch(frontier, { onChunkFailure: () => { @@ -3498,11 +3514,12 @@ async function runFullAnalysisInner( frontier = nextFrontier; } } - const importerExpansion = writableFiles.size - directlyChangedCount; + const importerExpansion = + writableFiles.size - directlyChangedCount - resolutionGroupExpansion; incrementalStats = { changedFiles: hashDiff.changed.length + hashDiff.added.length + hashDiff.deleted.length, reparsedFiles: pipelineResult.reparsedFileCount, - affectedDependents: importerExpansion, + affectedDependents: resolutionGroupExpansion + importerExpansion, deletedFiles: hashDiff.deleted.length, writeMode: 'incremental', }; @@ -3521,9 +3538,9 @@ async function runFullAnalysisInner( // 1. Compute the EFFECTIVE write-set (Finding 1). Two layers, // composed: - // (a) `writableFiles` — toWrite ∪ transitive importers of - // changed/deleted files (the bounded BFS above, reading - // IMPORTS from the pre-pipeline DB). + // (a) `writableFiles` — toWrite ∪ resolution siblings ∪ transitive + // importers of changed/deleted files and their siblings (the + // bounded BFS above, reading IMPORTS from the pre-pipeline DB). // (b) `computeEffectiveWriteSet` — walks the NEW graph's // edges and pulls in any unchanged-side file that sits // on a writable-boundary-crossing edge (catches refined diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index ab6bff2b6..92d89cceb 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -864,7 +864,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // scopes and declaration metadata. Re-extract both stores with provider fixes. // v140: Ruby accessor captures retain reader visibility and singleton ownership. // Older worker and durable captures cannot prove property-reader dispatch. -const SCHEMA_BUMP = 140; +// v141: detached method owners retain names without fabricated graph ids. +// Re-extract both worker and durable captures before workspace ownership runs. +const SCHEMA_BUMP = 141; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/decl.go b/gitnexus/test/fixtures/go-cross-file-struct-owner/decl.go new file mode 100644 index 000000000..113a239a6 --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/decl.go @@ -0,0 +1,10 @@ +package owners + +type Widget struct { Name string } +type Other struct {} + +func (w *Widget) Describe() string { return w.Name } + +func (*Widget) UnnamedPointerLocal() {} + +func (Widget) UnnamedValueLocal() {} diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/external_decl_test.go b/gitnexus/test/fixtures/go-cross-file-struct-owner/external_decl_test.go new file mode 100644 index 000000000..d4de56328 --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/external_decl_test.go @@ -0,0 +1,3 @@ +package owners_test + +type Widget struct { Name string } diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/external_methods_test.go b/gitnexus/test/fixtures/go-cross-file-struct-owner/external_methods_test.go new file mode 100644 index 000000000..203990e5c --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/external_methods_test.go @@ -0,0 +1,3 @@ +package owners_test + +func (w *Widget) Run() {} diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/foreign.rs b/gitnexus/test/fixtures/go-cross-file-struct-owner/foreign.rs new file mode 100644 index 000000000..38331d476 --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/foreign.rs @@ -0,0 +1,4 @@ +pub struct Widget {} +impl Widget { + pub fn run(&self) {} +} diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/go.mod b/gitnexus/test/fixtures/go-cross-file-struct-owner/go.mod new file mode 100644 index 000000000..9c07aa83e --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/go.mod @@ -0,0 +1,3 @@ +module example.com/owners + +go 1.22 diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/local.go b/gitnexus/test/fixtures/go-cross-file-struct-owner/local.go new file mode 100644 index 000000000..d96299b50 --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/local.go @@ -0,0 +1,6 @@ +package owners + +func buildLocal() { + type Widget struct { Count int } + _ = Widget{} +} diff --git a/gitnexus/test/fixtures/go-cross-file-struct-owner/methods.go b/gitnexus/test/fixtures/go-cross-file-struct-owner/methods.go new file mode 100644 index 000000000..eec26fb35 --- /dev/null +++ b/gitnexus/test/fixtures/go-cross-file-struct-owner/methods.go @@ -0,0 +1,10 @@ +package owners + +func (w *Widget) Rename(n string) { w.Name = n } +func (w Widget) Clone() Widget { return w } +func (w *Widget) Run() {} +func (o *Other) Run() {} + +func (*Widget) UnnamedPointer() {} + +func (Widget) UnnamedValue() {} diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/callers.go b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/callers.go new file mode 100644 index 000000000..71b2800d6 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/callers.go @@ -0,0 +1,7 @@ +package model + +func dispatch(work Work, worker *Worker, other Other) int { + return work.Calculate() + worker.Calculate() + other.Compute(2) +} +const label = "Compute" +// Compute on Other has a different signature and does not implement Work. diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/go.mod b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/methods.go b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/methods.go new file mode 100644 index 000000000..8ffbae542 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/methods.go @@ -0,0 +1,4 @@ +package model + +func (worker *Worker) Calculate() int { return 1 } +func (other Other) Compute(value int) int { return value } diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/types.go b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/types.go new file mode 100644 index 000000000..696b11523 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/types.go @@ -0,0 +1,5 @@ +package model + +type Work interface { Calculate() int } +type Worker struct {} +type Other struct {} diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/callers.go b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/callers.go new file mode 100644 index 000000000..488070472 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/callers.go @@ -0,0 +1,7 @@ +package model + +func dispatch(work Work, worker *Worker, other Other) int { + return work.Compute() + worker.Compute() + other.Compute(2) +} +const label = "Compute" +// Compute on Other has a different signature and does not implement Work. diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/go.mod b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/go.mod new file mode 100644 index 000000000..047567b68 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/go.mod @@ -0,0 +1,3 @@ +module example.com/renamefixture + +go 1.22 diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/methods.go b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/methods.go new file mode 100644 index 000000000..f641f89fc --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/methods.go @@ -0,0 +1,4 @@ +package model + +func (worker *Worker) Compute() int { return 1 } +func (other Other) Compute(value int) int { return value } diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/types.go b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/types.go new file mode 100644 index 000000000..8507162ce --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/types.go @@ -0,0 +1,5 @@ +package model + +type Work interface { Compute() int } +type Worker struct {} +type Other struct {} diff --git a/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/case.json b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/case.json new file mode 100644 index 000000000..02b1896a7 --- /dev/null +++ b/gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/case.json @@ -0,0 +1,50 @@ +{ + "language": "go", + "description": "Rename a pointer receiver method whose owner, interface and callers live in sibling files; preserve unrelated same-name methods, comments and strings.", + "categories": ["cross-file", "method-family", "interface", "pointer-receiver", "homonyms"], + "target": { + "name": "Compute", + "kind": "Method", + "file": "methods.go", + "startLine": 2 + }, + "newName": "Calculate", + "expect": { + "status": "success", + "edits": 4 + }, + "edges": [ + { + "type": "HAS_METHOD", + "from": { "name": "Worker", "kind": "Struct", "file": "types.go" }, + "to": { "name": "Compute", "kind": "Method", "file": "methods.go", "startLine": 2 } + }, + { + "type": "HAS_METHOD", + "from": { "name": "Other", "kind": "Struct", "file": "types.go" }, + "to": { "name": "Compute", "kind": "Method", "file": "methods.go", "startLine": 3 } + }, + { + "type": "METHOD_IMPLEMENTS", + "from": { "name": "Compute", "kind": "Method", "file": "methods.go", "startLine": 2 }, + "to": { "name": "Compute", "kind": "Method", "file": "types.go", "startLine": 2 } + }, + { + "type": "CALLS", + "from": { "name": "dispatch", "kind": "Function", "file": "callers.go" }, + "to": { "name": "Compute", "kind": "Method", "file": "types.go", "startLine": 2 } + }, + { + "type": "CALLS", + "from": { "name": "dispatch", "kind": "Function", "file": "callers.go" }, + "to": { "name": "Compute", "kind": "Method", "file": "methods.go", "startLine": 2 } + }, + { + "type": "CALLS", + "from": { "name": "dispatch", "kind": "Function", "file": "callers.go" }, + "to": { "name": "Compute", "kind": "Method", "file": "methods.go", "startLine": 3 } + } + ], + "exactOutgoing": true, + "warmParseCache": true +} diff --git a/gitnexus/test/integration/go-build-variant-owners.test.ts b/gitnexus/test/integration/go-build-variant-owners.test.ts new file mode 100644 index 000000000..723968556 --- /dev/null +++ b/gitnexus/test/integration/go-build-variant-owners.test.ts @@ -0,0 +1,128 @@ +import { expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo, findDanglingEdges } from './resolvers/helpers.js'; +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; +import { goScopeResolver } from '../../src/core/ingestion/languages/go/scope-resolver.js'; +import { populateGoWorkspaceOwners } from '../../src/core/ingestion/languages/go/method-owners.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/index.js'; +import { reconcileOwnership } from '../../src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js'; +import { buildGraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import { resolveDefGraphId } from '../../src/core/ingestion/scope-resolution/graph-bridge/ids.js'; + +it('keeps build-variant owners consistent across real workers, semantic registry and graph', async () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'go-build-owners-')); + const files = new Map(); + const expected: string[] = []; + const add = (file: string, body: string, tag = '') => { + files.set(file, `${tag ? `//go:build ${tag}\n\n` : ''}package example\n${body}\n`); + }; + const own = (declaration: string, method: string, name = 'Run') => { + expected.push(`Struct:${declaration}:Value -> Method:${method}:Value.${name}#0`); + }; + // Same-file variants reproduce the regression; sibling declarations require + // the workspace pass to select by the method's constraints, not file order. + for (const platform of ['linux', 'windows']) { + const local = `local/value_${platform}.go`; + add(local, 'type Value struct {}\nfunc (v Value) Run() {}'); + own(local, local); + const declaration = `sibling/value_${platform}.go`; + const method = `sibling/method_${platform}.go`; + add(declaration, 'type Value struct {}'); + add(method, 'func (v *Value) Run() {}'); + own(declaration, method); + } + for (const [file, tag] of [ + ['fast', 'fast && (cgo || debug)'], + ['fallback', '!(fast && (cgo || debug))'], + ]) { + add(`tagged/${file}.go`, 'type Value struct {}', tag); + add(`tagged/${file}_methods.go`, 'func (v Value) Run() {}', tag); + own(`tagged/${file}.go`, `tagged/${file}_methods.go`); + } + for (const arch of ['amd64', 'arm64']) { + const file = `arch/value_linux_${arch}_test.go`; + add(file, 'type Value struct {}\nfunc (Value) Run() {}'); + own(file, file); + } + // Overlapping constraints still decline, including same-file provisional + // owners. Linux files also build on Android, so those are NOT disjoint. + for (const platform of ['linux', 'android']) { + add(`overlap/value_${platform}.go`, 'type Value struct {}\nfunc (v Value) Run() {}'); + } + add('duplicate/a.go', 'type Value struct {}\nfunc (v Value) Run() {}', 'fast'); + add('duplicate/b.go', 'type Value struct {}', 'fast || debug'); + // A shared method can refer to either variant: one ownerId cannot encode it. + add('shared/value_linux.go', 'type Value struct {}'); + add('shared/value_windows.go', 'type Value struct {}'); + add('shared/method.go', 'func (v Value) Run() {}'); + // Mutually exclusive sole candidates must not produce impossible edges. + add('incompatible/value_linux.go', 'type Value struct {}'); + add('incompatible/method_windows.go', 'func (v Value) Run() {}'); + // A bounded header scan can leave a valid method's constraints unknown. + // That must not revive an owner whose platform constraints are impossible. + const unknownMethod = '// license '.repeat(2000) + '\npackage example\nfunc (v Value) Run() {}\n'; + add('impossible/value_linux.go', 'type Value struct {}', 'windows'); + files.set('impossible/method.go', unknownMethod); + add('unknown/value_linux.go', 'type Value struct {}', 'windows'); + add('unknown/value.go', 'type Value struct {}'); + files.set('unknown/method.go', unknownMethod); + own('unknown/value.go', 'unknown/method.go'); + // Invalid modern constraints remain possible: they cannot eliminate a + // competitor and promote a provisional same-file owner to a unique choice. + add('malformed/local.go', 'type Value struct {}\nfunc (v Value) Run() {}', '!fast'); + add('malformed/competitor.go', 'type Value struct {}', '!!fast'); + add('malformed_space/local.go', 'type Value struct {}\nfunc (v Value) Run() {}', '!fast'); + add('malformed_space/competitor.go', 'type Value struct {}', 'fast\u00a0&& debug'); + try { + fs.writeFileSync(path.join(repo, 'go.mod'), 'module example.com/variants\n\ngo 1.22\n'); + for (const [file, content] of files) { + fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + fs.writeFileSync(path.join(repo, file), content); + } + const result = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + chunkByteBudget: 1, + }); + expect(result.usedWorkerPool).toBe(true); + expect(result.scopeExtractionFailures).toEqual([]); + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const graphOwners = [...result.graph.iterRelationshipsByType('HAS_METHOD')] + .map((edge) => `${edge.sourceId} -> ${edge.targetId}`) + .sort(); + expect(graphOwners).toEqual(expected.sort()); + + const parsedFiles = [...files].map(([file, source]) => { + const parsed = extractParsedFile(goScopeResolver.languageProvider, source, file); + if (!parsed) throw new Error(`Scope extraction failed for ${file}`); + goScopeResolver.populateOwners(parsed); + return parsed; + }); + // Reverse the input to ensure first/last declaration order cannot decide. + populateGoWorkspaceOwners(parsedFiles.reverse(), { fileContents: files }); + const model = createSemanticModel(); + expect(reconcileOwnership(parsedFiles, model).methodsRegistered).toBe(expected.length); + const defs = new Map( + parsedFiles.flatMap((file) => file.localDefs).map((def) => [def.nodeId, def]), + ); + const lookup = buildGraphNodeLookup(result.graph); + const semanticOwners = [...defs.values()] + .filter((def) => def.type === 'Method' && def.ownerId) + .map((def) => { + if (!def.ownerId) throw new Error(`Missing owner for ${def.nodeId}`); + const owner = defs.get(def.ownerId); + if (!owner) throw new Error(`Unknown owner ${def.ownerId}`); + expect((def as { goReceiverKind?: string }).goReceiverKind).toBe( + def.filePath.startsWith('sibling/') ? 'pointer' : 'value', + ); + expect(model.methods.lookupAllByOwner(owner.nodeId, 'Run')).toContainEqual(def); + return `${resolveDefGraphId(owner.filePath, owner, lookup)} -> ${resolveDefGraphId(def.filePath, def, lookup)}`; + }) + .sort(); + expect(semanticOwners).toEqual(graphOwners); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/gitnexus/test/integration/go-cross-file-struct-owner.test.ts b/gitnexus/test/integration/go-cross-file-struct-owner.test.ts new file mode 100644 index 000000000..afa7e588a --- /dev/null +++ b/gitnexus/test/integration/go-cross-file-struct-owner.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo, findDanglingEdges } from './resolvers/helpers.js'; +import type { PipelineResult } from './resolvers/helpers.js'; +import { + emptyParseCache, + loadParseCache, + loadParseCacheChunk, + PARSE_CACHE_VERSION, + saveParseCache, +} from '../../src/storage/parse-cache.js'; +import { mergeStagedDurableParsedFileStore } from '../../src/storage/parsedfile-store.js'; +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; +import { goScopeResolver } from '../../src/core/ingestion/languages/go/scope-resolver.js'; +import { populateGoWorkspaceOwners } from '../../src/core/ingestion/languages/go/method-owners.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/index.js'; +import { reconcileOwnership } from '../../src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js'; +import { buildGraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import { resolveDefGraphId } from '../../src/core/ingestion/scope-resolution/graph-bridge/ids.js'; + +const FIXTURE = path.resolve(__dirname, '../fixtures/go-cross-file-struct-owner'); +const EXPECTED_OWNERS = [ + 'Struct:decl.go:Other -> Method:methods.go:Other.Run#0', + 'Struct:decl.go:Widget -> Method:decl.go:Widget.Describe#0', + 'Struct:decl.go:Widget -> Method:decl.go:Widget.UnnamedPointerLocal#0', + 'Struct:decl.go:Widget -> Method:decl.go:Widget.UnnamedValueLocal#0', + 'Struct:decl.go:Widget -> Method:methods.go:Widget.Clone#0', + 'Struct:decl.go:Widget -> Method:methods.go:Widget.Rename#1', + 'Struct:decl.go:Widget -> Method:methods.go:Widget.Run#0', + 'Struct:decl.go:Widget -> Method:methods.go:Widget.UnnamedPointer#0', + 'Struct:decl.go:Widget -> Method:methods.go:Widget.UnnamedValue#0', + 'Struct:external_decl_test.go:Widget -> Method:external_methods_test.go:Widget.Run#0', +].sort(); + +function goMethodOwners(result: PipelineResult): string[] { + return [...result.graph.iterRelationshipsByType('HAS_METHOD')] + .filter((edge) => result.graph.getNode(edge.targetId)?.properties.language === 'go') + .map((edge) => `${edge.sourceId} -> ${edge.targetId}`) + .sort(); +} + +// Real parser workers, semantic resolution, graph emission and durable cache. +describe('Go semantic member ownership (#3558, #3559)', () => { + let temp: string; + let repo: string; + let storage: string; + let cold: PipelineResult; + let cache: ReturnType; + + beforeAll(async () => { + temp = fs.mkdtempSync(path.join(os.tmpdir(), 'go-semantic-owner-')); + repo = path.join(temp, 'repo'); + storage = path.join(temp, 'cache'); + fs.cpSync(FIXTURE, repo, { recursive: true }); + fs.mkdirSync(storage); + cache = emptyParseCache(storage); + cold = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + chunkByteBudget: 1, + parseCache: cache, + }); + const savedKeys = await saveParseCache(storage, cache); + await mergeStagedDurableParsedFileStore( + storage, + storage, + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + }); + afterAll(() => fs.rmSync(temp, { recursive: true, force: true })); + + it('attaches methods to exact owners across worker chunks and receiver forms', () => { + expect(cold.usedWorkerPool).toBe(true); + expect(cold.scopeExtractionFailures).toEqual([]); + expect(cache.usedKeys.size).toBeGreaterThan(1); + expect(goMethodOwners(cold)).toEqual(EXPECTED_OWNERS); + expect(findDanglingEdges(cold, ['HAS_METHOD', 'HAS_PROPERTY'])).toEqual([]); + }); + + it('keeps external test packages and other languages separate and preserves fields', () => { + for (const edge of cold.graph.iterRelationshipsByType('HAS_METHOD')) { + const owner = cold.graph.getNode(edge.sourceId)!; + const method = cold.graph.getNode(edge.targetId)!; + expect(owner.properties.language).toBe(method.properties.language); + if (method.properties.filePath === 'external_methods_test.go') + expect(owner.properties.filePath).toBe('external_decl_test.go'); + } + expect( + [...cold.graph.iterRelationshipsByType('HAS_PROPERTY')].some( + (edge) => + edge.sourceId === 'Struct:decl.go:Widget' && + cold.graph.getNode(edge.targetId)?.properties.name === 'Name', + ), + ).toBe(true); + }); + + it('uses the same owners in the semantic method registry and the graph', () => { + const fileContents = new Map( + fs + .readdirSync(FIXTURE) + .filter((file) => file.endsWith('.go')) + .map((file) => [file, fs.readFileSync(path.join(FIXTURE, file), 'utf8')]), + ); + const parsedFiles = [...fileContents].map(([file, content]) => { + const parsed = extractParsedFile(goScopeResolver.languageProvider, content, file); + if (!parsed) throw new Error(`Scope extraction failed for ${file}`); + goScopeResolver.populateOwners(parsed); + return parsed; + }); + populateGoWorkspaceOwners(parsedFiles, { fileContents }); + const model = createSemanticModel(); + expect(reconcileOwnership(parsedFiles, model).methodsRegistered).toBe(10); + const defs = new Map( + parsedFiles.flatMap((parsed) => parsed.localDefs).map((def) => [def.nodeId, def]), + ); + const lookup = buildGraphNodeLookup(cold.graph); + const owners = [...defs.values()] + .filter((def) => def.type === 'Method') + .map((def) => { + expect(def.ownerId).toBeDefined(); + const owner = defs.get(def.ownerId!)!; + const name = def.qualifiedName!.split('.').pop()!; + if (name.startsWith('Unnamed')) { + expect((def as { goReceiverKind?: string }).goReceiverKind).toBe( + name.includes('Pointer') ? 'pointer' : 'value', + ); + } + expect(model.methods.lookupAllByOwner(owner.nodeId, name)).toContainEqual(def); + return `${resolveDefGraphId(owner.filePath, owner, lookup)} -> ${resolveDefGraphId(def.filePath, def, lookup)}`; + }); + expect(owners.sort()).toEqual(EXPECTED_OWNERS); + }); + + it('does not cache fabricated owner IDs or ownership edges from parser workers', async () => { + const stored = await loadParseCache(storage); + const chunks = await Promise.all( + [...cache.usedKeys].map((key) => loadParseCacheChunk(stored, key)), + ); + const results = chunks.flatMap((chunk) => chunk ?? []); + const deferred = results + .flatMap((result) => result.symbols) + .filter((symbol) => symbol.type === 'Method' && symbol.filePath === 'methods.go'); + expect(deferred).toHaveLength(6); + expect(deferred.every((symbol) => symbol.ownerId === undefined)).toBe(true); + const methodIds = new Set(deferred.map((symbol) => symbol.nodeId)); + expect( + results + .flatMap((result) => result.relationships) + .filter((edge) => edge.type === 'HAS_METHOD' && methodIds.has(edge.targetId)), + ).toEqual([]); + }); + + it('reconstructs identical ownership on durable warm-cache replay without workers', async () => { + const warm = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + chunkByteBudget: 1, + parseCache: await loadParseCache(storage), + }); + expect(warm.usedWorkerPool).toBe(false); + expect(warm.reparsedFileCount).toBe(0); + expect(warm.parseCacheHitFileCount).toBeGreaterThan(0); + expect(goMethodOwners(warm)).toEqual(EXPECTED_OWNERS); + expect(findDanglingEdges(warm, ['HAS_METHOD', 'HAS_PROPERTY'])).toEqual([]); + }); + + it('re-resolves a cached method when its declaring file moves', async () => { + fs.renameSync(path.join(repo, 'decl.go'), path.join(repo, 'moved.go')); + try { + const moved = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + chunkByteBudget: 1, + parseCache: await loadParseCache(storage), + }); + expect(moved.parseCacheHitFileCount).toBeGreaterThan(0); + expect(goMethodOwners(moved)).toEqual( + EXPECTED_OWNERS.map((edge) => edge.replaceAll(':decl.go:', ':moved.go:')).sort(), + ); + expect(findDanglingEdges(moved, ['HAS_METHOD', 'HAS_PROPERTY'])).toEqual([]); + } finally { + fs.renameSync(path.join(repo, 'moved.go'), path.join(repo, 'decl.go')); + } + }); +}); + +it('declines unresolved and ambiguous owners, including same-file candidates', async () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'go-ambiguous-owner-')); + try { + fs.writeFileSync(path.join(repo, 'go.mod'), 'module example.com/ambiguous\n\ngo 1.22\n'); + fs.writeFileSync( + path.join(repo, 'a.go'), + 'package example\ntype Widget struct {}\nfunc (w Widget) Local() {}\n', + ); + fs.writeFileSync(path.join(repo, 'b.go'), 'package example\ntype Widget struct {}\n'); + fs.writeFileSync( + path.join(repo, 'methods.go'), + 'package example\nfunc (w Widget) Run() {}\nfunc (w Missing) Lost() {}\n', + ); + const result = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + }); + expect([...result.graph.iterNodes()].filter((node) => node.label === 'Method')).toHaveLength(3); + expect(goMethodOwners(result)).toEqual([]); + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/gitnexus/test/integration/go-incremental-method-owners.test.ts b/gitnexus/test/integration/go-incremental-method-owners.test.ts new file mode 100644 index 000000000..402536e34 --- /dev/null +++ b/gitnexus/test/integration/go-incremental-method-owners.test.ts @@ -0,0 +1,107 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { closeLbug, executeQuery, initLbug } from '../../src/core/lbug/lbug-adapter.js'; +import { runFullAnalysis } from '../../src/core/run-analyze.js'; +import { getStoragePaths } from '../../src/storage/repo-manager.js'; +import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const WINDOWS_FILE = 'variants/value_windows.go'; +const WINDOWS_SOURCE = 'package variants\ntype Value struct {}\n'; +const LINUX_OWNER = { + owner: 'Struct:variants/value_linux.go:Value', + method: 'Method:variants/method.go:Value.Run#0', +}; +const SENTINEL_OWNER = { + owner: 'Struct:other/value.go:Sentinel', + method: 'Method:other/value.go:Sentinel.Keep#0', +}; + +async function readOwners(repo: string): Promise[]> { + await initLbug(getStoragePaths(repo).lbugPath, { skipFts: true }); + try { + return await executeQuery( + "MATCH (s:Struct)-[r:CodeRelation]->(m:Method) WHERE r.type = 'HAS_METHOD' " + + 'RETURN s.id AS owner, m.id AS method ORDER BY owner, method', + ); + } finally { + await closeLbug(); + } +} + +it('persists ownership changes between untouched siblings after deleting and readding a build variant', async () => { + const temp = await createTempDir(); + try { + const root = await fs.realpath(temp.dbPath); + vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined); + vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only'); + const repo = path.join(root, 'incremental'); + const coldRepo = path.join(root, 'cold'); + const sources = new Map([ + ['.gitignore', '.gitnexus/\n'], + ['go.mod', 'module example.com/owners\n\ngo 1.22\n'], + ['variants/value_linux.go', 'package variants\ntype Value struct {}\n'], + [WINDOWS_FILE, WINDOWS_SOURCE], + ['variants/method.go', 'package variants\nfunc (v Value) Run() {}\n'], + ['other/value.go', 'package other\ntype Sentinel struct {}\nfunc (s Sentinel) Keep() {}\n'], + ]); + for (const target of [repo, coldRepo]) { + for (const [file, source] of sources) { + await fs.mkdir(path.dirname(path.join(target, file)), { recursive: true }); + await fs.writeFile(path.join(target, file), source); + } + initGitRepo(target); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: target }); + commitAll(target, 'ambiguous receiver owner'); + } + const options = { skipAgentsMd: true, skipSkills: true, skipFts: true }; + const callbacks = { onProgress: () => {} }; + const expectColdParity = async (expected: Record[]): Promise => { + // The control rebuilds from source with a fresh parser pass at each stage. + await runFullAnalysis(coldRepo, { ...options, force: true, useParseCache: false }, callbacks); + const cold = await readOwners(coldRepo); + expect(cold).toEqual(expected); + expect(await readOwners(repo)).toEqual(cold); + }; + + await runFullAnalysis(repo, options, callbacks); + await expectColdParity([SENTINEL_OWNER]); + + // Neither endpoint of the newly resolved edge is edited. The only changed + // path is gone, so it has no new-graph boundary edge or source to inspect. + for (const target of [repo, coldRepo]) { + await fs.unlink(path.join(target, WINDOWS_FILE)); + commitAll(target, 'remove Windows declaration'); + } + const removed = await runFullAnalysis(repo, options, callbacks); + expect(removed.incrementalStats).toMatchObject({ + changedFiles: 1, + deletedFiles: 1, + writeMode: 'incremental', + }); + await expectColdParity([SENTINEL_OWNER, LINUX_OWNER]); + + // Ambiguity must also remove the stored edge; no current HAS_METHOD edge + // remains for boundary expansion to discover either unchanged endpoint. + for (const target of [repo, coldRepo]) { + await fs.writeFile(path.join(target, WINDOWS_FILE), WINDOWS_SOURCE); + commitAll(target, 'restore Windows declaration'); + } + const restored = await runFullAnalysis(repo, options, callbacks); + expect(restored.incrementalStats).toMatchObject({ + changedFiles: 1, + deletedFiles: 0, + writeMode: 'incremental', + }); + await expectColdParity([SENTINEL_OWNER]); + } finally { + await closeLbug(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } +}, 180_000); diff --git a/gitnexus/test/unit/go-route-handler.test.ts b/gitnexus/test/unit/go-route-handler.test.ts index 64fbfa4f9..5a8736a30 100644 --- a/gitnexus/test/unit/go-route-handler.test.ts +++ b/gitnexus/test/unit/go-route-handler.test.ts @@ -2,15 +2,13 @@ * `resolveGoRouteHandler` (#3402): gin/echo handler designators resolved across * the files of a Go package, unique-or-decline at every step. * - * The model is built the way the parse phase leaves it: a Method's `ownerId` is - * the worker's `Struct::` id, keyed on the method's own - * file — not the struct's node id when the struct lives in a sibling file. + * The model is built the way the parse phase leaves it: detached Methods have + * a receiver-qualified name, with ownership deferred to semantic resolution. */ import { describe, expect, it } from 'vitest'; import { createSemanticModel } from '../../src/core/ingestion/model/index.js'; import { resolveGoRouteHandler } from '../../src/core/ingestion/languages/go/route-handler.js'; import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js'; -import { generateId } from '../../src/lib/utils.js'; const ROUTER = 'app/router/router.go'; @@ -19,10 +17,10 @@ type Model = ReturnType; const struct = (model: Model, file: string, name: string) => model.symbols.add(file, name, `Struct:${file}:${name}`, 'Struct'); -/** A method as the worker registers it: owner keyed on the METHOD's file. */ +/** A method as the worker registers it before workspace ownership resolves. */ const method = (model: Model, file: string, receiver: string, name: string) => model.symbols.add(file, name, `Method:${file}:${receiver}.${name}`, 'Method', { - ownerId: generateId('Struct', `${file}:${receiver}`), + qualifiedName: `${receiver}.${name}`, }); const fn = (model: Model, file: string, name: string, returnType?: string) => @@ -52,7 +50,10 @@ describe('resolveGoRouteHandler', () => { it('deduplicates a method indexed by both its canonical owner and receiver name', () => { const model = createSemanticModel(); struct(model, 'app/router/h.go', 'H'); - method(model, 'app/router/h.go', 'H', 'Do'); + model.symbols.add('app/router/h.go', 'Do', 'Method:app/router/h.go:H.Do', 'Method', { + ownerId: 'Struct:app/router/h.go:H', + qualifiedName: 'H.Do', + }); const context = { model, importTargetsFor: () => [] }; const registration = route({ handlerName: 'h.Do', diff --git a/gitnexus/test/unit/has-method.test.ts b/gitnexus/test/unit/has-method.test.ts index 2262f05b1..edcb5110f 100644 --- a/gitnexus/test/unit/has-method.test.ts +++ b/gitnexus/test/unit/has-method.test.ts @@ -9,9 +9,11 @@ import CSharp from 'tree-sitter-c-sharp/bindings/node/index.js'; import Go from 'tree-sitter-go'; import { findEnclosingClassId, + findEnclosingClassInfo, CLASS_CONTAINER_TYPES, CONTAINER_TYPE_TO_LABEL, } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { goProvider } from '../../src/core/ingestion/languages/go.js'; function parseCode(language: any, code: string): Parser.Tree { const parser = new Parser(); @@ -450,9 +452,7 @@ struct Vector2 { }); describe('Go', () => { - it('returns receiver struct ID for Go methods', () => { - // Go methods have receiver parameter: func (s *Server) Start() {} - // findEnclosingClassId extracts the receiver type to link method → struct + it('retains the receiver name while deferring its owner ID to semantic resolution', () => { const tree = parseCode( Go, ` @@ -472,12 +472,19 @@ func (s *Server) Start() {} methodNode!, (n) => n.type === 'field_identifier' && n.text === 'Start', ); - if (nameNode) { - const result = findEnclosingClassId(nameNode, 'test/server.go'); - expect(result).not.toBeNull(); - // Should generate a Struct ID for "Server" - expect(result).toContain('Server'); - } + expect(nameNode).not.toBeNull(); + expect(findEnclosingClassId(nameNode!, 'test/server.go')).toBeNull(); + expect( + findEnclosingClassInfo( + nameNode!, + 'test/server.go', + undefined, + undefined, + undefined, + undefined, + goProvider.resolveDetachedOwnerName, + ), + ).toEqual({ className: 'Server' }); }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index dc63c06d3..9526547e1 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -322,8 +322,9 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 137 -> 138 for provider-owned rename occurrences and explicit flow owners (#3486). // Moved 138 -> 139 for provider graph/capture fixes exercised by real rename fixtures (#3486). // Moved 139 -> 140 for Ruby accessor ownership captures used by rename (#3557). - it('pins SCHEMA_BUMP to 140 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(140); + // Moved 140 -> 141 for detached Go ownership captures used by rename (#3559). + it('pins SCHEMA_BUMP to 141 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486, #3559)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(141); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -333,7 +334,7 @@ describe('PARSE_CACHE_VERSION', () => { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, - 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, + 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/incremental-resolution-groups.test.ts b/gitnexus/test/unit/incremental-resolution-groups.test.ts new file mode 100644 index 000000000..dcc3cd21f --- /dev/null +++ b/gitnexus/test/unit/incremental-resolution-groups.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { collectResolutionGroupFiles } from '../../src/core/incremental/resolution-groups.js'; + +describe('incremental resolution groups', () => { + const files = [ + 'pkg/value_linux.go', + 'pkg/method.go', + 'pkg/internal_test.go', + 'pkg/external_test.go', + 'pkg/other.ts', + 'pkg/sub/value.go', + 'elsewhere/value.go', + ]; + + it('includes current package siblings when the changed path was deleted', () => { + expect(collectResolutionGroupFiles(['pkg/value_windows.go'], files)).toEqual( + new Set(files.slice(0, 4)), + ); + }); + + it('groups added and edited members without crossing directories or languages', () => { + const changed = ['pkg/value_linux.go', 'pkg/method.go', 'pkg/new.go', 'elsewhere/value.go']; + expect(collectResolutionGroupFiles(changed, [...files, 'pkg/new.go'])).toEqual( + new Set([...files.slice(0, 4), 'pkg/new.go', 'elsewhere/value.go']), + ); + }); + + it('does not expand files whose provider has no resolution grouping hook', () => { + expect(collectResolutionGroupFiles(['pkg/other.ts', 'pkg/README.md'], files)).toEqual( + new Set(), + ); + expect(collectResolutionGroupFiles([], files)).toEqual(new Set()); + }); + + it('supports root packages and normalized directory keys for Windows paths', () => { + expect(collectResolutionGroupFiles(['removed.go'], ['method.go', 'sub/method.go'])).toEqual( + new Set(['method.go']), + ); + expect(collectResolutionGroupFiles(['pkg\\removed.go'], files)).toEqual( + new Set(files.slice(0, 4)), + ); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-build-constraints.test.ts b/gitnexus/test/unit/scope-resolution/go/go-build-constraints.test.ts new file mode 100644 index 000000000..a134a17cc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-build-constraints.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { + goFileBuildConstraint, + goBuildConstraintsMayOverlap, +} from '../../../../src/core/ingestion/languages/go/build-constraints.js'; + +const source = (tag = '') => `${tag ? `//go:build ${tag}\n\n` : ''}package example\n`; +const overlap = (a: string, b: string, aTag = '', bTag = '') => + goBuildConstraintsMayOverlap( + goFileBuildConstraint(a, source(aTag)), + goFileBuildConstraint(b, source(bTag)), + ); + +describe('Go build constraints for method ownership', () => { + it.each([ + ['value_linux.go', 'value_windows.go', false], + ['value_amd64.go', 'value_arm64.go', false], + ['value_linux_amd64_test.go', 'value_linux_arm64_test.go', false], + ['linux.go', 'value_windows.go', true], + ['value_unix.go', 'value_windows.go', true], + ['value_madeup.go', 'value_windows.go', true], + ['dir_linux/value.go', 'value_windows.go', true], + ['dir\\value_linux.go', 'value_windows.go', false], + ['value.v2_linux.go', 'value_windows.go', true], + ['value_linux.v2.go', 'value_windows.go', false], + ['value_linux_amd64_test.v2.go', 'value_linux_arm64.go', false], + ['value_linux.go', 'value_android.go', true], + ['value_darwin.go', 'value_ios.go', true], + ['value_solaris.go', 'value_illumos.go', true], + ])('compares %s with %s', (a, b, expected) => expect(overlap(a, b)).toBe(expected)); + + it.each([ + ['linux', 'windows', false], + ['linux', 'android', true], + ['linux && !android', 'android', false], + ['unix', 'windows', false], + ['!unix', 'darwin', false], + ['fast', '!fast', false], + ['!(!fast)', '!fast', false], + ['fast', 'debug', true], + ['fast && (cgo || debug)', '!(fast && (cgo || debug))', false], + ['fast || debug && cgo', '!fast && debug && !cgo', false], + ['fast || debug && cgo', '!fast && debug && cgo', true], + ['fast && !fast', '', false], + ['linux && windows', '', false], + ['amd64 && arm64', '', false], + ['go1.25', '!go1.24', true], // Unknown implications remain possible. + ])('compares %s with %s', (a, b, expected) => + expect(overlap('a.go', 'b.go', a, b)).toBe(expected), + ); + + it('combines filename and explicit constraints', () => { + expect(overlap('a_linux.go', 'b.go', '', '!linux')).toBe(false); + expect(overlap('a_linux.go', 'b.go', 'windows')).toBe(false); + }); + + it('does not let unknown constraints revive an impossible file', () => { + const unknown = goFileBuildConstraint( + 'method.go', + '// license '.repeat(2000) + '\npackage p\n', + ); + expect(unknown).toBeNull(); + for (const [file, tag] of [ + ['value.go', 'fast && !fast'], + ['value_linux.go', 'windows'], + ['value.go', 'amd64 && arm64'], + ['value.go', '(linux && windows) || (fast && !fast)'], + ]) { + const impossible = goFileBuildConstraint(file, source(tag)); + expect(goBuildConstraintsMayOverlap(unknown, impossible)).toBe(false); + expect(goBuildConstraintsMayOverlap(impossible, unknown)).toBe(false); + } + const possible = goFileBuildConstraint('value.go', source('(linux && windows) || fast')); + expect(goBuildConstraintsMayOverlap(unknown, possible)).toBe(true); + expect(goBuildConstraintsMayOverlap(possible, unknown)).toBe(true); + expect(goBuildConstraintsMayOverlap(unknown, unknown)).toBe(true); + }); + + it('reads modern directives around block comments, but not inside them or after code', () => { + const positive = goFileBuildConstraint('a.go', source('fast')); + for (const text of [ + '/* license */\n//go:build !fast\n\npackage p\n', + '//go:build !fast\n/* license */\npackage p\n', + ]) + expect(goBuildConstraintsMayOverlap(positive, goFileBuildConstraint('b.go', text))).toBe( + false, + ); + for (const text of [ + '/*\n//go:build !fast\n*/\npackage p\n', + 'package p\n//go:build !fast\n', + '//go:buildsomething !fast\npackage p\n', + ]) + expect(goBuildConstraintsMayOverlap(positive, goFileBuildConstraint('b.go', text))).toBe( + true, + ); + }); + + it('supports legacy line-AND, space-OR and comma-AND with modern precedence', () => { + const legacy = goFileBuildConstraint( + 'a.go', + '// +build fast,debug slow\n// +build cgo\n\npackage p\n', + ); + const opposite = goFileBuildConstraint('b.go', source('!((fast && debug || slow) && cgo)')); + expect(goBuildConstraintsMayOverlap(legacy, opposite)).toBe(false); + const modern = goFileBuildConstraint('a.go', '//go:build fast\n// +build !fast\n\npackage p\n'); + expect( + goBuildConstraintsMayOverlap(modern, goFileBuildConstraint('b.go', source('!fast'))), + ).toBe(false); + // Without the separating blank line, +build is package documentation. + expect( + goBuildConstraintsMayOverlap( + goFileBuildConstraint('a.go', '// +build !fast\npackage p\n'), + modern, + ), + ).toBe(true); + }); + + it('keeps malformed, missing and expensive constraints unknown', () => { + for (const text of [ + undefined, + '//go:build\n\npackage p\n', + source('fast &&'), + source('!!fast'), + source('! !fast'), + source('fast\u00a0&& debug'), + source('fast\r&& debug'), + source('(fast'), + '//go:build fast\n//go:build !fast\n\npackage p\n', + source(Array.from({ length: 8 }, (_, i) => `(a${i} || b${i})`).join(' && ')), + source('('.repeat(130) + 'fast' + ')'.repeat(130)), + '// license '.repeat(2000) + '\npackage p\n', + ]) { + const constraint = goFileBuildConstraint('a.go', text); + expect(constraint).toBeNull(); + expect( + goBuildConstraintsMayOverlap(constraint, goFileBuildConstraint('b.go', source('!fast'))), + ).toBe(true); + } + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 225bcf9cb..e150ff726 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -81,6 +81,7 @@ export default defineConfig({ 'test/integration/impact-context-integrity.test.ts', 'test/integration/incremental-worktree-context.test.ts', 'test/integration/incremental-method-integrity.test.ts', + 'test/integration/go-incremental-method-owners.test.ts', 'test/integration/query-symbol-integrity.test.ts', 'test/integration/mcp-index-generation.test.ts', 'test/integration/fts-repair-warm-session.test.ts', @@ -184,6 +185,7 @@ export default defineConfig({ 'test/integration/impact-context-integrity.test.ts', 'test/integration/incremental-worktree-context.test.ts', 'test/integration/incremental-method-integrity.test.ts', + 'test/integration/go-incremental-method-owners.test.ts', 'test/integration/query-symbol-integrity.test.ts', 'test/integration/mcp-index-generation.test.ts', 'test/integration/fts-repair-warm-session.test.ts',