mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-16 04:28:11 +00:00
fix(ingestion): preserve Go method ownership across files (#3559)
* fix(ingestion): re-point Go struct member edges when struct is declared in a sibling file (#3558) findEnclosingClassInfo mints a Go method's owner id from the file containing the method (Struct:<methodFile>:<Type>), while the Struct node is keyed by the file declaring the struct (parse-worker uses declFile). Same package, different file => the HAS_METHOD edge's sourceId points at a node that does not exist and the edge is silently dropped at COPY into LadybugDB. Add reconcileGoStructOwnerEdges, run at the end of the parse phase after ALL worker chunks are merged so every package's structs are visible. It rewrites only edges that would otherwise dangle, and only when exactly one Struct node shares the dangling id's package directory and simple name; 0 or >=2 candidates keep the old drop behavior (a wrong owner is worse than a dropped edge). Index build is lazy: dangling Struct sources are rare (struct fields never dangle — they live in the struct's own file), so the common non-Go or fully-same-file case pays two per-type edge scans and no whole-node pass. Regression test: test/integration/go-cross-file-struct-owner.test.ts (fails-before: 1 HAS_METHOD edge; passes-after: 3). * fix(ingestion): scope Go struct-owner reconcile by language and package Address PR #3559 review findings: - Scope the pass to Go: dangling HAS_METHOD/HAS_PROPERTY owner ids whose Struct:<file>:<name> file is not Go source (Rust, Swift, ...) are never re-pointed, and only Struct nodes stamped language 'go' (the marker the worker sets on every declared symbol) are candidates. Negative test pins a Rust-shaped dangling edge staying untouched beside a same-name Go struct. - Key candidate structs by (dir, declared package clause, name) instead of (dir, name): one directory can carry 'package pkg' and external-test 'package pkg_test' files declaring the same type name, which the old key saw as ambiguous and dropped fail-closed. Clauses are read via the shared inferGoPackageName derivation with a call-scoped cache. Integration test covers the 3-file layout (pkg decl, pkg_test decl, pkg_test methods file) and asserts the edge lands on the pkg_test.Widget node. - Keep the direct call in parse-impl with a TODO: the pass needs a graph-level post-merge hook, which no LanguageProvider/ScopeResolver hook provides today. * refactor(ingestion): remove Go graph repair from shared parse phase * fix(ingestion): resolve Go ownership during ingestion (language-provider.ts) * Add resolveDetachedOwnerName function to Go config * Refactor method owner population logic in Go * Refactor receiver binding to handle unnamed receivers * Enhance route handler method resolution logic Refactor route handler to improve ownership resolution and method indexing. * Add emitPostResolutionEdges to scope resolver * fix(ingestion): resolve Go ownership during ingestion (ownership.ts) * fix(ingestion): resolve Go ownership during ingestion (ast-helpers.ts) * Add resolveDetachedOwnerName to provider methods * Update SCHEMA_BUMP to 141 Increment schema version to 141 for new features and fixes. * fix(ingestion): resolve Go ownership during ingestion (decl.go) * fix(ingestion): resolve Go ownership during ingestion (external_decl_test.go) * fix(ingestion): resolve Go ownership during ingestion (external_methods_test.go) * fix(ingestion): resolve Go ownership during ingestion (foreign.rs) * fix(ingestion): resolve Go ownership during ingestion (go.mod) * fix(ingestion): resolve Go ownership during ingestion (local.go) * fix(ingestion): resolve Go ownership during ingestion (methods.go) * fix(ingestion): resolve Go ownership during ingestion (go-cross-file-struct-owner.test.ts) * Refactor comments and method registration in tests Updated comments for clarity and modified method registration to include qualified names and ownership. * fix(ingestion): resolve Go ownership during ingestion (has-method.test.ts) * Update SCHEMA_BUMP to 141 in tests * refactor(ingestion): remove obsolete Go struct owner reconciliation * fix(go): model build constraints for method ownership * test(go): cover build constraint grammar and platform variants * fix(go): resolve method owners across compatible build variants * test(go): verify variant ownership through ingestion pipeline This test verifies that build-variant owners are consistent across different workers, the semantic registry, and the graph. It sets up various scenarios to ensure that ownership is correctly resolved and that there are no dangling edges in the ownership graph. * fix(go): reject impossible known constraints when peer is unknown Address PR #3559 review feedback. Regression coverage follows in this update. * feat(ingestion): allow providers to group incremental resolution files Add the optional language-neutral hook used by the Go ownership writeback fix for PR #3559. * fix(go): group sibling files for incremental ownership resolution Use the existing Go package-directory key for the provider hook. * fix(incremental): collect sibling resolution groups for changed paths Keep group keys local to each provider and include current siblings of deleted files. * fix(analyze): persist ownership changes in unchanged Go siblings Expand changed and deleted resolution groups before importer traversal. Use the same effective file set for deletion and writeback so package-wide ownership changes reach the database. * test(incremental): cover provider-local resolution groups Cover changed, added, deleted and unrelated paths for the new sibling expansion helper. * test(go): compare incremental ownership writes with cold rebuilds Reproduce ownership changes after deleting and restoring a Windows declaration while the shared method file stays unchanged. Verify incremental writes and persisted ownership parity with forced rebuilds. * test(go): exclude impossible owners with unknown method constraints Use real parsing to cover impossible owner edges and valid owners hidden by impossible competitors. * test(go): pin filename rules and unknown constraint overlap Document Go first-dot filename behavior and verify impossible known constraints in both operand orders. * test(go): register incremental ownership coverage across platforms Include the new database-backed Go ownership suite in the cross-platform test list. * test(go): schedule incremental ownership suite with native database tests Complete PR #3559 review fixes. Local validation: 47 focused tests across five suites passed; build and source/test type checks passed. The full suite was interrupted without a result. * refactor(ingestion): centralize worker owner hook forwarding Pass the language provider into the private cached owner lookup instead of repeating four owner hooks at five call sites. Keep qualified-name selection explicit and preserve the exported helper signature. Validation: CLI/shared build and test typecheck pass; repository lint has no errors; 591 tests pass with one skipped across 13 ownership and worker suites. Web typecheck is blocked by uninstalled web dependencies. Graph checks are unavailable because this checkout has no registered index. * test(rename): add Go cross-file fixture module Part of the cross-file method ownership regression for PR #3559. The completed fixture uses real indexing, public rename preview/apply, warm cache replay and post-rename reindexing. All 14 Go rename integration cases pass locally. * test(rename): add cross-file fixture before/types.go * test(rename): add cross-file fixture before/methods.go * test(rename): add cross-file fixture before/callers.go * test(rename): add cross-file fixture after/go.mod * test(rename): add cross-file fixture after/types.go * test(rename): add cross-file fixture after/methods.go * test(rename): add cross-file fixture after/callers.go * Add test case for renaming pointer receiver method Complete the real persisted-index regression fixture for PR #3559. The type, pointer receiver method and callers are in separate Go files. Public rename preview/apply must make exactly four edits across the structural interface family while preserving an unrelated same-name method, comments and strings. The fixture checks six exact ownership, interface and call edges before rename, after warm parse-cache replay and after reindexing the edited sources. All 14 Go rename integration cases pass. Formatting and the CLI typecheck pass. Graph impact/detect-changes could not run because this checkout has no registered index. --------- Co-authored-by: System administrator <root@nixos-infra.home> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
a665031c96
commit
5c3961756b
39 changed files with 1259 additions and 117 deletions
|
|
@ -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',
|
||||
|
|
|
|||
31
gitnexus/src/core/incremental/resolution-groups.ts
Normal file
31
gitnexus/src/core/incremental/resolution-groups.ts
Normal file
|
|
@ -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<string> {
|
||||
const changedGroups = new Set<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
227
gitnexus/src/core/ingestion/languages/go/build-constraints.ts
Normal file
227
gitnexus/src/core/ingestion/languages/go/build-constraints.ts
Normal file
|
|
@ -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<Record<string, string>> = {
|
||||
android: 'linux',
|
||||
ios: 'darwin',
|
||||
illumos: 'solaris',
|
||||
};
|
||||
|
||||
type Term = ReadonlyMap<string, boolean>;
|
||||
/** 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<string, boolean>();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<string, { nodeId: string; qualifiedName: string }>();
|
||||
function populateGoOwnersInPackage(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
fileContents?: ReadonlyMap<string, string>,
|
||||
): 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<string, Map<string, Owner>>();
|
||||
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<string, Owner>();
|
||||
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<string, readonly Owner[]>();
|
||||
if (structByQualifiedName.size > 0) {
|
||||
for (const parsed of parsedFiles) {
|
||||
const compatibleFiles = new Map<string, boolean>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 ?? '<unnamed-receiver>';
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:<methodFile>:<Receiver>` 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string, string>();
|
||||
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: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string>(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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
gitnexus/test/fixtures/go-cross-file-struct-owner/decl.go
vendored
Normal file
10
gitnexus/test/fixtures/go-cross-file-struct-owner/decl.go
vendored
Normal file
|
|
@ -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() {}
|
||||
3
gitnexus/test/fixtures/go-cross-file-struct-owner/external_decl_test.go
vendored
Normal file
3
gitnexus/test/fixtures/go-cross-file-struct-owner/external_decl_test.go
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package owners_test
|
||||
|
||||
type Widget struct { Name string }
|
||||
3
gitnexus/test/fixtures/go-cross-file-struct-owner/external_methods_test.go
vendored
Normal file
3
gitnexus/test/fixtures/go-cross-file-struct-owner/external_methods_test.go
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package owners_test
|
||||
|
||||
func (w *Widget) Run() {}
|
||||
4
gitnexus/test/fixtures/go-cross-file-struct-owner/foreign.rs
vendored
Normal file
4
gitnexus/test/fixtures/go-cross-file-struct-owner/foreign.rs
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub struct Widget {}
|
||||
impl Widget {
|
||||
pub fn run(&self) {}
|
||||
}
|
||||
3
gitnexus/test/fixtures/go-cross-file-struct-owner/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/go-cross-file-struct-owner/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/owners
|
||||
|
||||
go 1.22
|
||||
6
gitnexus/test/fixtures/go-cross-file-struct-owner/local.go
vendored
Normal file
6
gitnexus/test/fixtures/go-cross-file-struct-owner/local.go
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package owners
|
||||
|
||||
func buildLocal() {
|
||||
type Widget struct { Count int }
|
||||
_ = Widget{}
|
||||
}
|
||||
10
gitnexus/test/fixtures/go-cross-file-struct-owner/methods.go
vendored
Normal file
10
gitnexus/test/fixtures/go-cross-file-struct-owner/methods.go
vendored
Normal file
|
|
@ -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() {}
|
||||
7
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/callers.go
vendored
Normal file
7
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/callers.go
vendored
Normal file
|
|
@ -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.
|
||||
3
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/renamefixture
|
||||
|
||||
go 1.22
|
||||
4
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/methods.go
vendored
Normal file
4
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/methods.go
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
package model
|
||||
|
||||
func (worker *Worker) Calculate() int { return 1 }
|
||||
func (other Other) Compute(value int) int { return value }
|
||||
5
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/types.go
vendored
Normal file
5
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/after/types.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package model
|
||||
|
||||
type Work interface { Calculate() int }
|
||||
type Worker struct {}
|
||||
type Other struct {}
|
||||
7
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/callers.go
vendored
Normal file
7
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/callers.go
vendored
Normal file
|
|
@ -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.
|
||||
3
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/renamefixture
|
||||
|
||||
go 1.22
|
||||
4
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/methods.go
vendored
Normal file
4
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/methods.go
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
package model
|
||||
|
||||
func (worker *Worker) Compute() int { return 1 }
|
||||
func (other Other) Compute(value int) int { return value }
|
||||
5
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/types.go
vendored
Normal file
5
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/before/types.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package model
|
||||
|
||||
type Work interface { Compute() int }
|
||||
type Worker struct {}
|
||||
type Other struct {}
|
||||
50
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/case.json
vendored
Normal file
50
gitnexus/test/fixtures/rename-integration/go/cross-file-method-owner/case.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
128
gitnexus/test/integration/go-build-variant-owners.test.ts
Normal file
128
gitnexus/test/integration/go-build-variant-owners.test.ts
Normal file
|
|
@ -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<string, string>();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
213
gitnexus/test/integration/go-cross-file-struct-owner.test.ts
Normal file
213
gitnexus/test/integration/go-cross-file-struct-owner.test.ts
Normal file
|
|
@ -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<typeof emptyParseCache>;
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
107
gitnexus/test/integration/go-incremental-method-owners.test.ts
Normal file
107
gitnexus/test/integration/go-incremental-method-owners.test.ts
Normal file
|
|
@ -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<Record<string, unknown>[]> {
|
||||
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<string, unknown>[]): Promise<void> => {
|
||||
// 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);
|
||||
|
|
@ -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:<methodFile>:<Receiver>` 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<typeof createSemanticModel>;
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
43
gitnexus/test/unit/incremental-resolution-groups.test.ts
Normal file
43
gitnexus/test/unit/incremental-resolution-groups.test.ts
Normal file
|
|
@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue