Merge branch 'main' into feat/render-deploy

This commit is contained in:
Gergő Magyar 2026-08-06 00:56:43 +01:00 • committed by GitHub
commit 85aeaf34bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1138 additions and 147 deletions

View file

@ -1,7 +1,7 @@
{
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
"go": {
"fingerprint": "c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832",
"fingerprint": "e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
@ -12,7 +12,8 @@
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.",
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303."
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.",
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5."
},
"cobol": {
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",

View file

@ -5,15 +5,20 @@ import type { ClassExtractionConfig } from '../../class-types.js';
export const goClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Go,
typeDeclarationNodes: ['type_declaration'],
// `type_spec`, not the enclosing `type_declaration` (#2837): one node per
// DECLARED TYPE, so a grouped `type ( A struct{…}; B struct{…} )` yields one
// each instead of one for the whole block. Every Go capture that reaches this
// extractor is anchored the same way, so the wrapper is never handed over —
// and accepting it would be worse than rejecting it, because picking one spec
// out of several with no reference point silently returns the FIRST type's
// name. A `null` here is loud; the wrong name is not.
typeDeclarationNodes: ['type_spec'],
fileScopeNodeTypes: ['package_clause'],
extractName(node) {
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
return typeSpec?.childForFieldName('name')?.text;
return node.childForFieldName('name')?.text;
},
extractType(node) {
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
const typeNode = typeSpec?.childForFieldName('type');
const typeNode = node.childForFieldName('type');
if (typeNode?.type === 'struct_type') return 'Struct';
if (typeNode?.type === 'interface_type') return 'Interface';
return undefined;

View file

@ -144,6 +144,15 @@ export const goProvider = defineLanguage({
descriptionExtractor: createLeadingDocDescriptionExtractor({
lineCommentPrefixes: ['//'],
lineDirectivePrefixes: ['//go:', '// +build', '//nolint', '//line'],
// #2837: Go type declarations anchor on the `type_spec`, whose
// `previousNamedSibling` is null for the ordinary `type T struct{…}` form —
// the godoc comment is a sibling of the enclosing `type_declaration`, one
// level up. Without this the doc silently vanished for EVERY Go struct and
// interface (measured: pre-#2837 `desc=YES`, post `none`), taking #2270's
// description out of both the embedding header and the FTS column.
// Functions/methods are unaffected — they anchor on their own declaration
// node, which does carry the comment as a previous sibling.
wrapperNodeTypes: ['type_declaration'],
}),
builtInNames: GO_BUILT_INS,

View file

@ -3,6 +3,7 @@ import type { SemanticModel } from '../../model/semantic-model.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js';
import { resolveInheritanceBaseInScope } from '../../scope-resolution/scope/walkers.js';
import { goPackageDir } from './package-clause.js';
type MethodSet = ReadonlyMap<string, readonly SymbolDefinition[]>;
type MutableMethodSet = Map<string, SymbolDefinition[]>;
@ -752,11 +753,13 @@ function signatureContextForFile(
};
}
/** The package directory, or `undefined` for a repo-root file.
*
* Shares `goPackageDir` with the package-clause resolver rather than repeating
* its normalize-and-slice (#2837): the two disagree only on how they spell "no
* directory", so the difference stays here, at the one call site that cares. */
function packageQualifierForFile(filePath: string): string | undefined {
const normalized = filePath.replace(/\\/g, '/');
const slash = normalized.lastIndexOf('/');
if (slash === -1) return undefined;
const packageDir = normalized.slice(0, slash);
const packageDir = goPackageDir(filePath);
return packageDir.length === 0 ? undefined : packageDir;
}

View file

@ -1,6 +1,12 @@
import type { ParsedFile } from 'gitnexus-shared';
import { logger } from '../../../logger.js';
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import { goPackageDir, inferGoPackageName } from './package-clause.js';
/** Bound on the sample of no-package-clause paths named in the warning. */
const SKIPPED_SAMPLE_CAP = 5;
/**
* Populate `ownerId` on Go Method defs by matching receiver types
* extracted from `@type-binding.self` captures against struct defs in
@ -27,14 +33,34 @@ export function populateGoWorkspaceOwners(
ctx: { readonly fileContents: ReadonlyMap<string, string> },
): void {
const filesByPackage = new Map<string, ParsedFile[]>();
// A file with no resolvable package clause is dropped from ownership
// resolution entirely — its methods never attach to a struct declared in a
// sibling file. That used to be a bare `continue` with no trace, which is the
// same false-safe silence #2813 was filed about. Report it once, bounded
// (mirrors the fan-out-cap warning in scope-resolution/pipeline/run.ts).
let skippedCount = 0;
const skippedSample: string[] = [];
for (const parsed of parsedFiles) {
const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (pkgName === null) continue;
const key = `${packageDir(parsed.filePath)}\0${pkgName}`;
const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (pkgName === null) {
// Count everything, retain only the sample — a misrouted vendored tree
// would otherwise accumulate one path reference per file to print five.
skippedCount += 1;
if (skippedSample.length < SKIPPED_SAMPLE_CAP) skippedSample.push(parsed.filePath);
continue;
}
const key = `${goPackageDir(parsed.filePath)}\0${pkgName}`;
const bucket = filesByPackage.get(key) ?? [];
bucket.push(parsed);
filesByPackage.set(key, bucket);
}
if (skippedCount > 0) {
logger.warn(
{ skippedFiles: skippedCount, sample: skippedSample },
'go: files with no resolvable package clause were excluded from method-owner ' +
'resolution (their methods cannot attach to structs declared in sibling files)',
);
}
for (const bucket of filesByPackage.values()) {
populateGoOwnersInPackage(bucket);
@ -107,14 +133,3 @@ function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void {
}
}
}
function inferPackageName(sourceText: string): string | null {
const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
return match?.[1] ?? null;
}
function packageDir(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const idx = normalized.lastIndexOf('/');
return idx === -1 ? '' : normalized.slice(0, idx);
}

View file

@ -0,0 +1,78 @@
/**
* Go package-clause resolution — the single derivation of a file's package
* identity (#2837).
*
* Both Go passes that bucket files by package (`populateGoWorkspaceOwners` and
* `populateGoPackageSiblings`) previously carried their own byte-identical copy
* of this, spelled as one unanchored multiline regex:
*
* sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m)
*
* With the `m` flag that matches the first line ANYWHERE in the file starting
* with `package <ident>` — comment bodies included. Measured against that exact
* expression: a header comment containing `package legacy_notes kept for
* history` yields `legacy_notes`, and an indented ` package helper old name`
* yields `helper`. A file that mis-infers its own package gets a bucket key no
* sibling shares, so it is isolated in BOTH passes: its methods never attach to
* structs declared in sibling files, and it exchanges no same-package bindings.
* Every field-receiver call in it then resolves to nothing, silently — the same
* per-file signature #2837 reported.
*
* The Go spec makes the correct rule exact rather than heuristic: a source
* file's first non-comment, non-blank token is `package`. So skip the leading
* run of whitespace and comments, then require the very next token to be the
* clause. Anything else is `null` — a truncated read, a misrouted non-Go file,
* an unparseable header — reported by the caller rather than guessed at.
*
* ONE rule governs every leniency below (the `\s+` separator, the shebang skip,
* CR-only line endings): a file this returns `null` for is dropped from BOTH
* passes, so refusing a shape the previous regex accepted is a silent
* regression, not a principled tightening. Be no stricter than the grammar.
*/
/** Leading trivia: whitespace, `//` lines, block comments. Sticky. */
const LEADING_TRIVIA = /(?:\s+|\/\/[^\n\r]*|\/\*[\s\S]*?\*\/)*/y;
/** The clause itself, anchored at the first non-trivia byte. `\s+` (not
* `[ \t]+`) because Go separates tokens by any whitespace: `package\nmain` is
* legal and tree-sitter parses it without error. */
const PACKAGE_CLAUSE = /package\s+([A-Za-z_][A-Za-z0-9_]*)/y;
/**
* The package name declared by this Go source text, or `null` when its first
* real token is not a package clause.
*
* Only the leading run before the clause is skipped — deliberately NOT a
* whole-file comment strip, which would be O(file) on every Go file and would
* also have to model string literals to stay correct.
*/
export function inferGoPackageName(sourceText: string): string | null {
let i = 0;
// A leading `#!` line: `gorun`-style scripts carry one. Only a FIRST-line
// `#!` is skipped; a `#` anywhere else still ends the scan.
if (sourceText.startsWith('#!')) {
const eol = sourceText.search(/[\n\r]/);
if (eol === -1) return null;
i = eol + 1;
}
// Whitespace (`\s` covers the BOM and every line ending), `//` lines and block
// comments — the run a Go file may carry before its clause. Sticky, so the
// header is skipped in place without slicing a copy of the file. An
// unterminated `/*` or a `//` running to EOF simply leaves `lastIndex` where
// the clause cannot match, so neither needs its own early return.
LEADING_TRIVIA.lastIndex = i;
LEADING_TRIVIA.exec(sourceText);
PACKAGE_CLAUSE.lastIndex = LEADING_TRIVIA.lastIndex;
return PACKAGE_CLAUSE.exec(sourceText)?.[1] ?? null;
}
/**
* The directory half of a Go package key. Go package identity is
* directory-scoped, so repeated `package main` directories must not see each
* other's unqualified names.
*/
export function goPackageDir(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const idx = normalized.lastIndexOf('/');
return idx === -1 ? '' : normalized.slice(0, idx);
}

View file

@ -2,6 +2,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { expandGoDotImports } from './expand-wildcards.js';
import { goPackageDir, inferGoPackageName } from './package-clause.js';
/**
* O(n²×d) where n = files per package, d = defs per file.
@ -27,9 +28,13 @@ export function populateGoPackageSiblings(
// must not see each other's unqualified names.
const packageByFile = new Map<string, string>();
for (const parsed of nonTestFiles) {
const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
// the two passes cannot disagree about a file's package (#2837). The
// no-clause case is reported there; warning twice for one fact would be
// noise.
const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (pkgName !== null) {
packageByFile.set(parsed.filePath, `${packageDir(parsed.filePath)}\0${pkgName}`);
packageByFile.set(parsed.filePath, `${goPackageDir(parsed.filePath)}\0${pkgName}`);
}
}
@ -71,17 +76,6 @@ export function populateGoPackageSiblings(
}
}
function inferPackageName(sourceText: string): string | null {
const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
return match?.[1] ?? null;
}
function packageDir(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const idx = normalized.lastIndexOf('/');
return idx === -1 ? '' : normalized.slice(0, idx);
}
function getAugmentationBucket(
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
scopeId: ScopeId,

View file

@ -4,9 +4,33 @@ import Go from 'tree-sitter-go';
const GO_SCOPE_QUERY = `
;; Scopes
(source_file) @scope.module
;; One Class scope per DECLARED TYPE, not per declaration (#2837).
;;
;; Capturing the type_declaration made a grouped declaration
;; type (
;; Decoy struct { ... }
;; PickService struct { ... }
;; )
;; a SINGLE Class scope owning every struct in the block. Downstream that scope
;; can name only one owner -- buildWorkspaceResolutionIndex keeps the first
;; class-like def it finds -- so the block's structs lost their field type
;; bindings, typeOfMemberOnClass found no scope, the compound-receiver fold
;; declined, and every s.field.Method() site in the file emitted nothing at
;; all. Silent, per-file, and independent of file size: exactly the split
;; reported in #2837 that #2829's global fixes could not explain. Measured on
;; the go-grouped-type-decl fixture: EVERY struct in a grouped block lost its
;; edges, the first one included, and grouped interface blocks produced no
;; IMPLEMENTS edges at all.
;;
;; type_spec is the node Go's own grammar gives one declared type, so one
;; capture per type_spec is the granularity the rest of the pipeline already
;; assumes. A plain single-type declaration is unaffected in count -- only its
;; scope range narrows, from the type keyword to the name.
;;
;; NOTE: this string is a JS template literal. Backticks are a syntax error.
(type_declaration
(type_spec
type: [(struct_type) (interface_type)])) @scope.class
type: [(struct_type) (interface_type)]) @scope.class)
(function_declaration) @scope.function
(method_declaration) @scope.function
(func_literal) @scope.function
@ -22,14 +46,24 @@ const GO_SCOPE_QUERY = `
(communication_case) @scope.block
;; Declarations — struct
;;
;; Anchored on the type_spec, in lockstep with @scope.class above (#2837). Both
;; captures MUST name the same node: the def node and the class-scope node are
;; paired by range, so anchoring the def on the enclosing type_declaration while
;; the scope sits on the type_spec leaves the def strictly larger than its own
;; scope and NOTHING is owned -- measured as every Go field-receiver edge in the
;; fixture disappearing, plain declarations included. Keeping both on
;; type_declaration is the original bug: a grouped block gave both structs the
;; same capture node, so one silently displaced the other.
(type_declaration
(type_spec name: (type_identifier) @declaration.name
type: (struct_type))) @declaration.struct
type: (struct_type)) @declaration.struct)
;; Declarations — interface
;; Same lockstep requirement as @declaration.struct above (#2837).
(type_declaration
(type_spec name: (type_identifier) @declaration.name
type: (interface_type))) @declaration.interface
type: (interface_type)) @declaration.interface)
;; Declarations — function
(function_declaration

View file

@ -13,6 +13,9 @@
* symbol stays exact.
*/
import { createLogger } from '../../logger.js';
import { compareCodeUnits } from '../../../lib/utils.js';
import type { ResolutionOutcome } from './resolution-outcome.js';
/** Cap on distinct member names persisted. Well above what a real repo
@ -74,17 +77,55 @@ export interface UnresolvedReceiverSummary {
* `omitted` is the number of distinct names past the cap, so the caller can
* report truncation rather than silently losing entries.
*/
function rankAndCap(counts: Map<string, number>): {
function rankAndCap(
counts: Map<string, number>,
cap: number = MAX_UNRESOLVED_RECEIVER_MEMBERS,
): {
kept: [string, number][];
omitted: number;
} {
const ranked = [...counts.entries()].sort(
([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName),
// `compareCodeUnits`, not `localeCompare` (#2787). The tiebreak feeds the
// `.slice()` below, so locale-sensitive collation would decide WHICH
// entries survive the cap, not merely how they are listed — and ICU order
// varies by platform and ICU build, so two runs over one repo could persist
// different sets. Key-based lookup is unaffected either way.
([aName, aCount], [bName, bCount]) => bCount - aCount || compareCodeUnits(aName, bName),
);
const kept = ranked.slice(0, MAX_UNRESOLVED_RECEIVER_MEMBERS);
const kept = ranked.slice(0, cap);
return { kept, omitted: ranked.length - kept.length };
}
/**
* A `receiver-unresolved` drop at a CALL site.
*
* ONE predicate, shared by the persisted summary and the per-file diagnostic —
* the same contract `rankAndCap` states for its comparator. If the two ever
* disagreed, the diagnostic would rank a different population than the artifact
* it exists to explain.
*
* Call sites only: Case 0's recorder gates on the receiver's punctuation, not on
* what the reference IS, so property reads (`d.source.kind`) and writes
* (`x.argtypes = [...]`) arrive alongside lost method calls — measured at 25 of
* 124 drops on the fixture corpus. Counting them made the consumer's "N call
* sites invoking X were dropped" literally false. A missing `siteKind` counts as
* a call: the only emitter always sets it, and erring toward `lower-bound` is
* the safe direction for an epistemic signal.
*
* Receiver ORIGIN is deliberately NOT decided here — the summary routes external
* drops into their own bucket while the diagnostic excludes them, and collapsing
* that choice into this predicate would take it away from both callers.
*/
function isUnresolvedReceiverCall(
outcome: ResolutionOutcome,
): outcome is Extract<ResolutionOutcome, { kind: 'suppressed' }> {
return (
outcome.kind === 'suppressed' &&
outcome.reason === 'receiver-unresolved' &&
(outcome.siteKind === undefined || outcome.siteKind === 'call')
);
}
/**
* Build the summary, or `undefined` when nothing was dropped — an index with
* no unresolved receivers stores no key at all, so `epistemic` keeps its
@ -99,17 +140,10 @@ export function summarizeUnresolvedReceivers(
let totalSites = 0;
let externalSites = 0;
for (const outcome of outcomes) {
if (outcome.kind !== 'suppressed' || outcome.reason !== 'receiver-unresolved') continue;
// CALL sites only — see `isUnresolvedReceiverCall`, which the per-file
// diagnostic shares so the two can never rank different populations.
if (!isUnresolvedReceiverCall(outcome)) continue;
if (outcome.name.length === 0) continue;
// CALL sites only. Case 0's recorder gates on the receiver's punctuation, not
// on what the reference IS, so property reads (`d.source.kind`) and writes
// (`x.argtypes = [...]`) are recorded alongside lost method calls — measured at
// 25 of 124 drops on the fixture corpus. Counting them made the consumer's
// "N call sites invoking X were dropped" literally false, and flagged symbols
// whose CALL count was never short. `siteKind` exists to make this separable.
// A missing `siteKind` counts as a call: the only emitter always sets it, and
// erring toward `lower-bound` is the safe direction for an epistemic signal.
if (outcome.siteKind !== undefined && outcome.siteKind !== 'call') continue;
// Routed, not discarded. External-rooted drops (`console.log(...)`,
// `fetch(...)`) reach code this index does not contain, so there is no node
// an edge could have pointed at and nothing was lost — they must not hedge.
@ -194,3 +228,74 @@ export function lookupExternalCallCount(
if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined;
return sites;
}
/**
* Opt-in, following the repo's established diagnostic pattern
* (`createLogger(name, { debugEnvVar })`). Every repository in every language
* drops SOME receivers, so emitting this at `info` unconditionally would add a
* line to every analyze anyone ever runs — the noise ADV-6 flagged. #2837's
* reporter turns it on deliberately:
*
* GITNEXUS_DEBUG_RECEIVER_DROPS=1 gitnexus analyze
*/
// Built on first use, not at import. `createLogger` is eager where the `logger`
// singleton is a lazy Proxy — it resolves `pino-pretty`, constructs a SonicBoom
// destination and registers a `beforeExit` hook. This module is imported by
// `mcp/local/local-backend.ts` for its lookup helpers, so an eager call would
// put that on MCP server startup for a diagnostic that is off by default.
let receiverDropLog: ReturnType<typeof createLogger> | undefined;
const dropLog = (): ReturnType<typeof createLogger> =>
(receiverDropLog ??= createLogger('receiver-drops', {
debugEnvVar: 'GITNEXUS_DEBUG_RECEIVER_DROPS',
}));
/** Files named in the per-file receiver-drop line. Bounded: this is a signpost
* pointing at where to look, not an inventory. */
const UNRESOLVED_RECEIVER_FILE_SAMPLE = 10;
/**
* Report which FILES lost the most call sites to an untyped receiver (#2837).
*
* `summarizeUnresolvedReceivers` persists the same drops keyed by member NAME,
* capped at 500 distinct names, and discards `filePath` — deliberately, because
* its consumer (`impact()`'s exact-vs-lower-bound verdict) asks "is this
* symbol's caller count trustworthy", a question about names.
*
* That leaves "why does THIS file resolve nothing while its sibling resolves
* everything" unanswerable from any artifact, which is exactly what #2837 asked
* for and could not run: comparing the drop records of two files with identical
* declared shapes separates "receiver never typed" from "typed but no edge
* emitted", and narrows a per-file split in one run instead of a bisect.
*
* A log line rather than a persisted field because nothing queries it — a
* persisted `byFile` map would be an unread field carrying its own cap and
* truncation semantics. When a consumer appears, `UnresolvedReceiverSummary` is
* already a `RepoMeta` field and can carry it with no schema change.
*/
export function logUnresolvedReceiverFiles(outcomes: readonly ResolutionOutcome[]): void {
// Nothing below is observable unless the diagnostic is switched on, so skip
// the whole tally rather than building it and discarding it at the emit.
if (!dropLog().isLevelEnabled('debug')) return;
const byFile = new Map<string, number>();
let totalSites = 0;
for (const outcome of outcomes) {
// External-rooted drops (`fmt.Println`) reach code this index does not
// contain, so no node existed for an edge to point at and nothing was lost.
// Counting them here led the ranking with files that call `fmt` the most.
if (!isUnresolvedReceiverCall(outcome) || outcome.receiverOrigin === 'external') continue;
totalSites += 1;
byFile.set(outcome.filePath, (byFile.get(outcome.filePath) ?? 0) + 1);
}
if (totalSites === 0) return;
const { kept } = rankAndCap(byFile, UNRESOLVED_RECEIVER_FILE_SAMPLE);
dropLog().debug(
{
totalSites,
filesAffected: byFile.size,
topFiles: kept.map(([filePath, sites]) => ({ filePath, sites })),
},
'receiver-unresolved call sites by file (top offenders; a file far above its ' +
'siblings usually means its receivers were never typed, not that it has more calls)',
);
}

View file

@ -1121,8 +1121,15 @@ export const GO_QUERIES = `
(method_elem name: (field_identifier) @name) @definition.method
; Types
(type_declaration (type_spec name: (type_identifier) @name type: (struct_type))) @definition.struct
(type_declaration (type_spec name: (type_identifier) @name type: (interface_type))) @definition.interface
;
; Anchored on the type_spec, NOT the enclosing type_declaration (#2837) — a
; grouped type ( A struct{}; B struct{} ) block otherwise gave every match the
; same capture node, and goClassConfig.extractName resolved all of them to the
; FIRST spec's name, collapsing the block to one node. Must stay in lockstep
; with @scope.class / @declaration.struct in languages/go/query.ts, which
; carries the full rationale. (No backticks here: this is a template literal.)
(type_declaration (type_spec name: (type_identifier) @name type: (struct_type)) @definition.struct)
(type_declaration (type_spec name: (type_identifier) @name type: (interface_type)) @definition.interface)
; Imports
(import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import

View file

@ -881,20 +881,26 @@ export const findEnclosingClassInfo = (
}
}
}
// Go: type_declaration wrapping a struct_type (type User struct { ... })
if (current.type === 'type_declaration') {
const typeSpec = current.children?.find((c: SyntaxNode) => c.type === 'type_spec');
if (typeSpec) {
const typeBody = typeSpec.childForFieldName?.('type');
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
const nameNode = typeSpec.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return {
classId: generateId(label, `${filePath}:${nameNode.text}`),
className: nameNode.text,
};
}
// Go: the `type_spec` IS the declared type (`type User struct { ... }`, and
// one per member of a grouped `type ( A struct{…}; B struct{…} )` block).
//
// Matched here rather than on the enclosing `type_declaration` (#2837): this
// walk climbs `node.parent`, so it passes THROUGH the containing spec on its
// way up from any member, and the structure it already has is the answer.
// Keying on the wrapper instead meant picking one spec out of several with
// no reference point — which filed every member of a grouped block under its
// FIRST struct, so two same-named fields minted one id and first-write-wins
// dropped the second.
if (current.type === 'type_spec') {
const typeBody = current.childForFieldName?.('type');
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
const nameNode = current.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return {
classId: generateId(label, `${filePath}:${nameNode.text}`),
className: nameNode.text,
};
}
}
}

View file

@ -15,7 +15,10 @@ import { randomUUID } from 'node:crypto';
import { retryRename } from '../storage/fs-atomic.js';
import { acquireIndexLock } from '../storage/index-lock.js';
import { runPipelineFromRepo } from './ingestion/pipeline.js';
import { summarizeUnresolvedReceivers } from './ingestion/scope-resolution/unresolved-receivers.js';
import {
logUnresolvedReceiverFiles,
summarizeUnresolvedReceivers,
} from './ingestion/scope-resolution/unresolved-receivers.js';
import type { KnowledgeGraph } from './graph/types.js';
import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
import {
@ -2899,6 +2902,9 @@ async function runFullAnalysisInner(
const newFileHashesRecord: Record<string, string> = {};
for (const [k, v] of newFileHashes) newFileHashesRecord[k] = v;
const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? [];
logUnresolvedReceiverFiles(resolutionOutcomes);
// Annotated so the capabilities stamp below is compile-checked against
// RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated
// literal widens the vectorSearch.status ternary to `string` and the
@ -2968,9 +2974,7 @@ async function runFullAnalysisInner(
// Derived digest of the DDL this run created the tables from (#2798).
// Git-only: non-git repos never take the incremental path.
schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined,
unresolvedReceiverMembers: summarizeUnresolvedReceivers(
pipelineResult.resolutionOutcomes ?? [],
),
unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes),
analysisFeatures: currentAnalysisFeatures,
// Always stamped with the live resolved mode (#2331/#2339) — unlike
// `pdg` below, 'none' is a meaningful value to compare, not an

View file

@ -228,7 +228,27 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// cache would serve entries that are missing those matches entirely — the
// exact failure a bump exists to prevent. Verified against origin/main at
// a857f4c5a, which is still on 43, so 44 is free. RE-CHECK BEFORE MERGE.
const SCHEMA_BUMP = 44;
// 44 -> 45: #2837 re-anchors Go's struct/interface captures from the
// `type_declaration` onto the `type_spec` (`languages/go/query.ts`
// @scope.class/@declaration.struct/@declaration.interface, and GO_QUERIES
// @definition.struct/@definition.interface in `tree-sitter-queries.ts`). Every
// Go file declaring a type therefore emits DIFFERENT capture ranges — measured:
// 70 fixture digests moved with zero change in capture COUNT — and a grouped
// `type (...)` block emits nodes it previously did not emit at all. Parse-time,
// so a warm cache would replay pre-fix ParsedFiles and the fix would be a silent
// no-op on every incremental analyze while still passing every cold-run test.
//
// This branch originally took 44 and it COLLIDED: #2842 above merged first and
// claimed it. The ninth entry in this ledger, and the third EXACT clash. Worth
// recording HOW it was caught, because the pin test cannot catch it — both PRs
// asserted `toBe(44)`, which passes even when main is already 44, so the two
// capture schemas would have shared one PARSE_CACHE_VERSION and the durable
// ParsedFile store would have replayed pre-fix ParsedFiles verbatim for one of
// them. Only comparing against origin/main at MERGE time surfaces it.
// PR #2840 (Objective-C, draft) still claims 44 as well — it must move too.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 45;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -9,15 +9,15 @@
},
"go-ambiguous/internal/models/handler.go": {
"captureGroups": 9,
"digest": "737d7ca26cf03aebcae6b5df6a13267d400b42cd37799d72eda92db3485a1add"
"digest": "db813c607a3ade67157d57e1f1223bb3bf7e2f7885ca3237bea0c23b40499864"
},
"go-ambiguous/internal/other/handler.go": {
"captureGroups": 9,
"digest": "645a526694111ba6066715515ef4055d84f5c328505f7fa92d00b0b253d0aeca"
"digest": "57dcd1dacf4a1e2caf9ba0a6d6c79fd8ede4ea1492ed3b7eff8c7702b1386ca2"
},
"go-ambiguous/internal/services/user.go": {
"captureGroups": 9,
"digest": "0a988c48838a34eb7a6f29b324191c39c19087e728914fffa06bbb9221841d23"
"digest": "58bed05b21968f435b9c9abaa71602fdc0544724ef43e71d9eb6bdece4d5bfe1"
},
"go-assignment-chain/cmd/main.go": {
"captureGroups": 54,
@ -25,11 +25,11 @@
},
"go-assignment-chain/models/repo.go": {
"captureGroups": 8,
"digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240"
"digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507"
},
"go-assignment-chain/models/user.go": {
"captureGroups": 8,
"digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907"
"digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70"
},
"go-call-result-binding/cmd/main.go": {
"captureGroups": 17,
@ -37,7 +37,7 @@
},
"go-call-result-binding/models/user.go": {
"captureGroups": 10,
"digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d"
"digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b"
},
"go-calls/cmd/main.go": {
"captureGroups": 6,
@ -53,23 +53,23 @@
},
"go-chain-call/cmd/main.go": {
"captureGroups": 20,
"digest": "90aed1ac18f7d992d47f24b7a432b9077cdc113e1b356ba9bd4e430be81c76f2"
"digest": "5ee61ed7f61f3a1c5d1b1ceaa2a08f51422e01def6e03ef3eb0be1d49343e396"
},
"go-chain-call/models/repo.go": {
"captureGroups": 10,
"digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b"
"digest": "28d5828d139c5a0415eb559628c70dc2a6f625b9b0b6271dcf0e89e4a52fc2ef"
},
"go-chain-call/models/user.go": {
"captureGroups": 10,
"digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d"
"digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b"
},
"go-child-extends-parent/models/child.go": {
"captureGroups": 4,
"digest": "61dafb01f7616ab6d965bcf17197de315f57a606c7b39cb25a0f83b84508d86d"
"digest": "80db3a5331edffec08bfa8b73779a4fe361516f8bfdfa0b818e36e63bd0ee1ee"
},
"go-child-extends-parent/models/parent.go": {
"captureGroups": 8,
"digest": "19a2b1696d45ea66cd00df562b65ea0b2e9fdbe60233cf31587a334221420d6e"
"digest": "e81975670b9e19c132231b801ff1cce77526a5a5681b2b9a47b0e07c03bce162"
},
"go-child-extends-parent/services/app.go": {
"captureGroups": 10,
@ -89,11 +89,11 @@
},
"go-constructor-type-inference/models/repo.go": {
"captureGroups": 8,
"digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240"
"digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507"
},
"go-constructor-type-inference/models/user.go": {
"captureGroups": 12,
"digest": "bf814d4db5d2b0026caab6aa5ade9c173abd96fdf2c1e127b57a854d0203f9a5"
"digest": "11b64a43661a469adc9b070cba520618ce487bbbd58e331db3dce97930f6906a"
},
"go-deep-field-chain/cmd/main.go": {
"captureGroups": 14,
@ -101,7 +101,7 @@
},
"go-deep-field-chain/models/models.go": {
"captureGroups": 33,
"digest": "3941943919b0066138d8a759cceadd95784c8a89978e7a345ea056ef1ca78c9c"
"digest": "9bbba66d384a47803aef95ef963224bbb80838340cdfb67560f2af5e81aa637a"
},
"go-field-types/cmd/main.go": {
"captureGroups": 10,
@ -109,7 +109,7 @@
},
"go-field-types/models/models.go": {
"captureGroups": 22,
"digest": "78de0f686b33993bd361a1d5366982afbe5378f4a62464c4248e3c9b65aaa9c3"
"digest": "9125a5a04d25873b3628447f74302a577a391c12f67f433fc75dbdc702173a5c"
},
"go-for-call-expr/cmd/main.go": {
"captureGroups": 27,
@ -117,47 +117,71 @@
},
"go-for-call-expr/models/repo.go": {
"captureGroups": 14,
"digest": "33889573461301951f51786e94a60a1d20c87bd3d2cad12f8619e951b94ad7ed"
"digest": "c1d3bd4514bfc74acbe3ce966d52c5456a1474df12367b32ed6efb0f82ff0ed6"
},
"go-for-call-expr/models/user.go": {
"captureGroups": 14,
"digest": "964cf1bd181cc7196726c3261a683884ac9c6fc3aee6170b47bd54427a6cb947"
"digest": "094432863997d5215dc91bc382fc52241f7d148dcab0f146ba8ff3551f40b709"
},
"go-grouped-type-decl/internal/repository/interfaces.go": {
"captureGroups": 11,
"digest": "6653543ddae1c7f9d3cc216cc074035c73c7da561bf495f3c1b0a07a8c97745e"
},
"go-grouped-type-decl/internal/repository/order_repo.go": {
"captureGroups": 19,
"digest": "fdfa1730082b98c793f83c0a0d454327316ef526f050c1cc4161e0675ea2320c"
},
"go-grouped-type-decl/internal/repository/sinks.go": {
"captureGroups": 23,
"digest": "f21caf7ae1cecf25e112cc28596b0ec2c0af060a5c05e5fbd8005c84674f8cd7"
},
"go-grouped-type-decl/internal/services/pick_service.go": {
"captureGroups": 32,
"digest": "1bb068b1ca6c3baa6ca6ef3c97f3a50777b05e5c6d80408be8084c13ea7dde0b"
},
"go-grouped-type-decl/internal/services/sort_service.go": {
"captureGroups": 21,
"digest": "75f8e4b967d249d5d0bef69812ed1ec040a06db7359ec691aba9165340930c98"
},
"go-grouped-type-decl/internal/services/wave_service.go": {
"captureGroups": 17,
"digest": "3bc5ef120e5c4648ac31d1e80ade828990521513b8380d2a7f45875ba990dc9c"
},
"go-inc-dec-write-access/main.go": {
"captureGroups": 23,
"digest": "fd35d917bc7a039ef4bcb0a6501c31ffbea0fe39bf3604938191597415aa33f7"
"digest": "a10c68642a9f4390765ea4679edeb38af54414e0d05d53f54161c3ff74976090"
},
"go-interface-field-dispatch/internal/handlers/orders.go": {
"captureGroups": 22,
"digest": "438b02c0990fa6c830767327d07de642f7aff6287e95217c3dfd7423a520942c"
"digest": "922a4f0220ba8e4363e455b2cf4185e3f83c0e5593b3296f575f5900a612f0f5"
},
"go-interface-field-dispatch/internal/handlers/picking.go": {
"captureGroups": 28,
"digest": "0ad401ba19e7cf7deace88ea7973572cb6ee2a63a6006aa5cc06f55bac56e14b"
"digest": "9ce10f70ed1e54e9fbc9affbbf36976046f432b70049807abe88189ad1d19f10"
},
"go-interface-field-dispatch/internal/repository/audit_repo.go": {
"captureGroups": 18,
"digest": "2710feb4bee288248e346d7ab0a32a8511b002d8c0fad4cd7db3588ee1926bc2"
"digest": "7c7dc2d8b41c59889d924c7e8289c77f946918efb9edf7e8a9663ccb3336705f"
},
"go-interface-field-dispatch/internal/repository/interfaces.go": {
"captureGroups": 10,
"digest": "ee5b78fc03ea3283b2e06b9b8d0cf4999c2de091262571966d1d4670036c479d"
"digest": "308b8afd93a78d33551c18e39ffd133d4aa1440251c3b5dca89ba26ccc1d980f"
},
"go-interface-field-dispatch/internal/repository/mock_repo.go": {
"captureGroups": 47,
"digest": "7b9219cc06e8c45fb08c304b71ebc58e1a9e052cb9c92fd01b9f22ab8328a91d"
"digest": "72d39d85e44d59fdbd69b9a3805403fad0781db0cd269af34f1e523cef78659c"
},
"go-interface-field-dispatch/internal/repository/order_repo.go": {
"captureGroups": 26,
"digest": "00e0f98586043ff84f96af92d9e6b3593018b6b37f92d183b5c05c6de745e2b0"
"digest": "50316b51850b2e8828b7a6910aa61b82375af4e94039a2caf6f292e858b4665d"
},
"go-interface-field-dispatch/internal/services/pick_service.go": {
"captureGroups": 39,
"digest": "036d1a4b7304bbbf963f640710fd286702ed34809df2940411c14da8f9de9a6f"
"digest": "907ecf7608704c44a2ef84ff10e2ada7d3c998d933c7af14e9c967b11ac4cb10"
},
"go-interface-field-dispatch/internal/services/wave_service.go": {
"captureGroups": 46,
"digest": "62a3830311106e4a349c5b604e914531249aacc47001a888ecf5d38e2bc4c821"
"digest": "99ce17d5757170222d98bde469d0ae0d1a41f386cdd4894d4f0d246debb90894"
},
"go-local-shadow/cmd/main.go": {
"captureGroups": 14,
@ -173,7 +197,7 @@
},
"go-make-builtin/models.go": {
"captureGroups": 15,
"digest": "2af382fcd2e59cf01d9439bdb8709cba150bb239fae71b6f0d09c1fa7248fc5b"
"digest": "631ab2da86be2dbc2ef9aa61e834c30b3ad83b9a8af570c4cbcebe97841a607a"
},
"go-map-range/main.go": {
"captureGroups": 11,
@ -181,11 +205,11 @@
},
"go-map-range/models/repo.go": {
"captureGroups": 9,
"digest": "6cbc4422fb287007735b4c58b5e9c84bbd6b8f6082e3b4d5fe48c306e6acc75b"
"digest": "5cd6835db0a273e1c651222b0fd3c1155ad94f39dbda8cd886fda6b8d8830962"
},
"go-map-range/models/user.go": {
"captureGroups": 9,
"digest": "7e4dbc05ad1de859cd3103c27cddb60566d95a8a8354c752e8c75f84d7ca055c"
"digest": "38bdb58e980de1327db28cb9110c2c20eff329decab4d62ac0ac2f57d0bf5d7c"
},
"go-member-calls/cmd/main.go": {
"captureGroups": 11,
@ -193,7 +217,7 @@
},
"go-member-calls/models/user.go": {
"captureGroups": 8,
"digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907"
"digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70"
},
"go-method-chain-binding/cmd/main.go": {
"captureGroups": 22,
@ -201,11 +225,11 @@
},
"go-method-chain-binding/models/user.go": {
"captureGroups": 24,
"digest": "d72468ce0567ce35393b570e7e3e86f0c004e37a9614dc5ee698aff6d3a4348a"
"digest": "2b3759d03ee88c548be2bceec060d0cb385713f191b7a0d508eb2852a09028a2"
},
"go-method-enrichment/animal.go": {
"captureGroups": 17,
"digest": "090d1bba93a5271a8e8e75d2e9d9790e58e4c5a6679b7fb9290f318591eb619b"
"digest": "ad4cfabcd53397ab664ba4bb05df4e1a803455b5c255a19e2ed283c6523a1660"
},
"go-method-enrichment/app.go": {
"captureGroups": 15,
@ -217,7 +241,7 @@
},
"go-mixed-chain/models/models.go": {
"captureGroups": 41,
"digest": "afb1eaf2869f534033c5cd844ca111a93b63df1c098cec9c9518c4480a47ddb0"
"digest": "6e467299d440a015b039470d82c33d5ab92ae92b131887607172ced38bccd761"
},
"go-multi-assign/app.go": {
"captureGroups": 18,
@ -225,7 +249,7 @@
},
"go-multi-assign/models.go": {
"captureGroups": 19,
"digest": "e071ebdd59b5bbebb42785ebf850a9c0ee7c2e3a8cc13fb0b17233480efa26ed"
"digest": "28ef663decb5e16b8cdbf16216849dff53d1e898a2cbd1dd1e68c116ce426cd8"
},
"go-multi-return-inference/cmd/main.go": {
"captureGroups": 38,
@ -233,11 +257,11 @@
},
"go-multi-return-inference/models/repo.go": {
"captureGroups": 10,
"digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b"
"digest": "28d5828d139c5a0415eb559628c70dc2a6f625b9b0b6271dcf0e89e4a52fc2ef"
},
"go-multi-return-inference/models/user.go": {
"captureGroups": 10,
"digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d"
"digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b"
},
"go-new-builtin/main.go": {
"captureGroups": 12,
@ -245,7 +269,7 @@
},
"go-new-builtin/models.go": {
"captureGroups": 17,
"digest": "d5ce8ba6ff38c6c78bb87e746a5382822745d0c9841a8092495ff386c7c4ee24"
"digest": "c00fe49983ed36e5af137f9c6b230256b957b84ff92c0590535a040edaa1dde0"
},
"go-nullable-receiver/cmd/main.go": {
"captureGroups": 27,
@ -253,19 +277,19 @@
},
"go-nullable-receiver/models/repo.go": {
"captureGroups": 8,
"digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240"
"digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507"
},
"go-nullable-receiver/models/user.go": {
"captureGroups": 8,
"digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907"
"digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70"
},
"go-parent-resolution/models/base.go": {
"captureGroups": 8,
"digest": "a5138789f8e35111dced225841a4d0e209eabad582973a55f5d47f882cf777e8"
"digest": "63f491f7e7968f96591fce93df493c03d1aca05ec1c60853a0e967116e29be8e"
},
"go-parent-resolution/models/user.go": {
"captureGroups": 9,
"digest": "e1b57b0c5865d84b80518fe52e3807f813ae5589b4b558d0037e6ea136843f85"
"digest": "190a4879616347c89d97ded78d4eb0dd6aba9e0a03cfbd756197ca2f8c8118e7"
},
"go-pkg/cmd/main.go": {
"captureGroups": 15,
@ -277,15 +301,15 @@
},
"go-pkg/internal/models/admin.go": {
"captureGroups": 17,
"digest": "087b8188a2e3d5d58f70d5a812b1a31a135dbd435de0ee7799d63199a38eb6cf"
"digest": "e238db8934192ba88102b0c78b63cd792c60480fd0a95a5c49ff6f79760c0640"
},
"go-pkg/internal/models/repository.go": {
"captureGroups": 5,
"digest": "73a5c9bfcb59011f36c47e1b806edba89bad1ed32f81d46acbefdb8abdba67c7"
"digest": "0020141e695e4ab262316a50bc7df86fdc6c4210217b4d502f4e7145110fba71"
},
"go-pkg/internal/models/user.go": {
"captureGroups": 14,
"digest": "04a3005d9f97325b79c0ac524a424c84c004aed5af24318389a413462dee2c4f"
"digest": "90ebac927e246e8f5da7c61beefdf3ab18f3dba2f487c5750b7c910d85615212"
},
"go-pointer-constructor-inference/cmd/main.go": {
"captureGroups": 15,
@ -293,35 +317,35 @@
},
"go-pointer-constructor-inference/models/repo.go": {
"captureGroups": 10,
"digest": "94b6f138a062dfd96969dc7ec3d3eddecab591cb29d16fed4ea91da20ff0363b"
"digest": "28d5828d139c5a0415eb559628c70dc2a6f625b9b0b6271dcf0e89e4a52fc2ef"
},
"go-pointer-constructor-inference/models/user.go": {
"captureGroups": 10,
"digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d"
"digest": "b63d9f87f84b4bdab8bab924fe116502dddaf871b2d1ffb00618a6799abb811b"
},
"go-pointer-receiver-field-chain/handlers/handler.go": {
"captureGroups": 116,
"digest": "ba27c7e7ddc71de0a2696963a50a1afcd2b0faed9716c55c02ce66a4232cd7fa"
"digest": "5d691e53c06ee365863626aea70c968eabc8c6e71f1ae53e4f93be26a685538b"
},
"go-pointer-receiver-field-chain/repository/repo.go": {
"captureGroups": 20,
"digest": "9e1c600c201dd4f5fdcb15820aaa3ad7fa1a3722e4c29bdf8daadf2e2f9f4b9e"
"digest": "78d5a003c1d6a2e200789a266261278ac6a24dea3112af86ac8d0c73773671d3"
},
"go-qualified-base/base/base.go": {
"captureGroups": 17,
"digest": "c2f2241a4e31ad1b003c1649d7511437f3884815412aeac65754cf3cfb9b6dc1"
"digest": "9130b5df9871611f0f28e86b7a78219da44c08eb244abc4f45e2aa3b45bfa588"
},
"go-qualified-base/consumers/local.go": {
"captureGroups": 20,
"digest": "923a41030b83b854118c12cb6f99ca02a8347b1db72ec8304904d3a99284f5f4"
"digest": "7165b0d6e3808ab29ee30b311ed387b59016d6fc8399e854e58f2e4affe1d467"
},
"go-qualified-base/consumers/qualified.go": {
"captureGroups": 14,
"digest": "4680bb7a7c62a54a91d1d7b8ab3150dfb0c54b1683bee8cf7fc747afe99ca0f8"
"digest": "9b1882cd492a04c605a5e6d32f2e264970eb8d4f359511d76b0ede6d8bff60c9"
},
"go-receiver-method-free-call/example.go": {
"captureGroups": 8,
"digest": "0b773eaede711e43ed8b8ae6aacf76c398dc362f54429b30a4f5eb7c71f863da"
"digest": "ec13b52723d8078edbf36d3f85abd6d58fc6e2bfec781b978ff57659c7f0676a"
},
"go-receiver-method-free-call/util.go": {
"captureGroups": 4,
@ -333,11 +357,11 @@
},
"go-receiver-resolution/models/repo.go": {
"captureGroups": 8,
"digest": "63cb96d06478f4b2039d6eaeab1468a8af3c503ec45e5785ff3e4de5aabe9240"
"digest": "05d37c0347bc18023c551bbe805be28ceb742d0bc97798f6d80b8df5c02ce507"
},
"go-receiver-resolution/models/user.go": {
"captureGroups": 8,
"digest": "fcee44ed373eda2ff00778ff951fc2fa1503c5187b9e7d43dea3ee8b63656907"
"digest": "5178178770dc01525d5fe29cfc88e5c59fe71f87f235855b1bb5d71dd479fa70"
},
"go-return-type-inference/cmd/main.go": {
"captureGroups": 41,
@ -345,11 +369,11 @@
},
"go-return-type-inference/models/repo.go": {
"captureGroups": 17,
"digest": "1117b573967ac1c687fe336f476f3c4e94680925f9a3ff2a84a47a87bdf86208"
"digest": "082f55e0526f824d570e807da706be940abea9584041d87d18a329c9ce198ffb"
},
"go-return-type-inference/models/user.go": {
"captureGroups": 17,
"digest": "a83045c31b12d9dc45b07315b081bef26ec380479d39706ee8c5d79e4efe5d88"
"digest": "f208647af1057fe1819f68e62d4afe7dae891af386a4cc688d2d60840a0fbd00"
},
"go-same-package-factory/main.go": {
"captureGroups": 14,
@ -357,11 +381,11 @@
},
"go-same-package-factory/repo.go": {
"captureGroups": 8,
"digest": "a5488a5ebe483c88d9f5bb2ccf0c283e397b3345798509c0e8e5ab65272be7fd"
"digest": "694a509ecf0f496fd72c89c0d2f9bb4ed8969b1b08cdf51a76b8c265e8f12158"
},
"go-same-package-factory/user.go": {
"captureGroups": 8,
"digest": "067c51c54c15b71f3ec45b8da94ea693e6b440d30fa26122d0a90a3b81f42bce"
"digest": "96208d52047f9e72853919baa3ba1aa9a9efa79e53b8aeca1085fe1d27dc48eb"
},
"go-split-method-owner/main.go": {
"captureGroups": 9,
@ -369,7 +393,7 @@
},
"go-split-method-owner/repo.go": {
"captureGroups": 8,
"digest": "a5488a5ebe483c88d9f5bb2ccf0c283e397b3345798509c0e8e5ab65272be7fd"
"digest": "694a509ecf0f496fd72c89c0d2f9bb4ed8969b1b08cdf51a76b8c265e8f12158"
},
"go-split-method-owner/save.go": {
"captureGroups": 6,
@ -377,7 +401,7 @@
},
"go-split-method-owner/user.go": {
"captureGroups": 3,
"digest": "827dc0208b47776976313a4560fbd250ab7fa521ae46581213a79fc15fd52ce8"
"digest": "831b367d3e84a054d94fe53e7093f7f09a9ba021c1d62c1cbd1283671a762c09"
},
"go-struct-literals/app.go": {
"captureGroups": 11,
@ -385,11 +409,11 @@
},
"go-struct-literals/user.go": {
"captureGroups": 10,
"digest": "16f0d425b25bdc7eaaf331bd47ce1ece6d476ae0e24e72110ce96c1f315bd314"
"digest": "c86101a45757cb20669616e7de23a688bd41316c200baab7cd13a394c30647a5"
},
"go-structural-interface-cross-package/api/repository.go": {
"captureGroups": 12,
"digest": "b5ef588c11391fa93237260baae4c4bb67ee4dfc381651444a9925c3f52adec3"
"digest": "e9b0ef4fea2112f18dfcda7cfca5c3a936c6a1977fbfed33c0dbd86929635d34"
},
"go-structural-interface-cross-package/cmd/main.go": {
"captureGroups": 33,
@ -397,23 +421,23 @@
},
"go-structural-interface-cross-package/contracts/read_closer.go": {
"captureGroups": 6,
"digest": "0813deb9e72bfb1e07d0e9399c4d4537dc40181f0c5c43cff5580b91d4ed3605"
"digest": "c76628ad75f2062808833697c4cc71852efa193a6505ed48e7855f0c7daec2c1"
},
"go-structural-interface-cross-package/impl/file.go": {
"captureGroups": 20,
"digest": "950002bd5dfc126d13355f317944a509253fe6dd81f12638af1d5f0fe37d04db"
"digest": "471dd17baa970f470cd7ee0f1df7b778876db9d73c2a39ad54aa30f7afda672b"
},
"go-structural-interface-cross-package/other/user.go": {
"captureGroups": 5,
"digest": "4d6298ea05addadfa17f6dd8a2eb0fa2ea63b73de69355015594504ae4e6c1b1"
"digest": "c70128808449498017a9633dd721254ad614ef5d10ce8b8113d235b785793b86"
},
"go-structural-interface-cross-package/store/repository.go": {
"captureGroups": 37,
"digest": "1b69c40092cd49588936e68bfb831d1ccfd5764bcb5bf42279bd66d9cbc57450"
"digest": "ad0a631017b863b9b0b8d47a34d1fa2e1029a0b9e7fd4b17e72607e7e2dc1724"
},
"go-structural-interface-dispatch/repository.go": {
"captureGroups": 142,
"digest": "5bb89d0695cd94082b0657abd3435726372fafa5c570d01b1440ac604217574b"
"digest": "a40aca0e28f3201d34d8f24aaf71f476144fdb5a613c0ffe2738f9901659dcb7"
},
"go-type-assertion/main.go": {
"captureGroups": 12,
@ -421,7 +445,7 @@
},
"go-type-assertion/models.go": {
"captureGroups": 18,
"digest": "018658521c83d757464357f9963cd5952e040be01a02d9f1d41304f3748fd1a5"
"digest": "6e894e96dc0287118d6543bf7c7bcd6c0ae87ad5d1b812e612364c5e4465d340"
},
"go-variadic-resolution/cmd/main.go": {
"captureGroups": 6,
@ -433,10 +457,10 @@
},
"go-write-access/main.go": {
"captureGroups": 21,
"digest": "20c312ccec297b19022c681a486656b0f2d5d3562c791588df170043f4e45b09"
"digest": "c4c86b2d32c5fbf075dbdbe66280b67fe01061748936e19f6c8551edb391ac07"
},
"synthetic:dao-20": {
"captureGroups": 521,
"digest": "cd4e6169abfe6016f000a884acf0604f7fea0ea00d9e1f8f52c7d5ad9b97a01a"
"digest": "6e79c31cdc169cb7ef7eff2282d8497c5c74fb81107837071dad9029f95bc9ac"
}
}

View file

@ -0,0 +1,3 @@
module github.com/example/grouped
go 1.21

View file

@ -0,0 +1,20 @@
package repository
// OrderRepository is the interface held as a struct FIELD by every service in
// this fixture. Pointer-receiver implementors only (#2813 shape).
type OrderRepository interface {
DeleteItem(id string) error
GetPickQueue(id string) ([]string, error)
}
// Grouped INTERFACE declaration — the same `type (...)` collapse that hits
// structs also hits interfaces, so both are pinned here (#2837).
type (
AuditSink interface {
LogAudit(msg string) error
}
MetricSink interface {
Observe(name string) error
}
)

View file

@ -0,0 +1,9 @@
package repository
type OrderRepo struct {
dsn string
}
func (r *OrderRepo) DeleteItem(id string) error { return nil }
func (r *OrderRepo) GetPickQueue(id string) ([]string, error) { return nil, nil }

View file

@ -0,0 +1,13 @@
package repository
type AuditWriter struct {
path string
}
func (w *AuditWriter) LogAudit(msg string) error { return nil }
type MetricWriter struct {
ns string
}
func (w *MetricWriter) Observe(name string) error { return nil }

View file

@ -0,0 +1,35 @@
package services
import "github.com/example/grouped/internal/repository"
// THE #2837 SHAPE. Identical field shape to WaveService, declared SECOND in a
// grouped `type (...)` block.
//
// `@scope.class` is captured on the `type_declaration`, so this whole block is
// ONE Class scope owning both structs; `buildWorkspaceResolutionIndex` keeps
// only the FIRST class-like def per scope, so `PickService` gets no
// `classScopeByDefId` entry and every `s.orderRepo` call site here resolves to
// nothing.
//
// `Decoy.orderRepo` deliberately shares the field NAME with a DIFFERENT type:
// both structs' field typeBindings live in one name-keyed map while the scope
// is shared, so this also pins that PickService is not typed by Decoy's field.
type (
Decoy struct {
orderRepo *LocalThing
}
PickService struct {
orderRepo repository.OrderRepository
}
)
type LocalThing struct {
n int
}
func (l *LocalThing) DeleteItem(id string) error { return nil }
func (s *PickService) Release(id string) error {
return s.orderRepo.DeleteItem(id)
}

View file

@ -0,0 +1,20 @@
package services
import "github.com/example/grouped/internal/repository"
// ORDER CONTROL: same grouped shape as pick_service.go, but the service struct
// is declared FIRST. If only this one resolves, the fix is order-luck, not a
// fix (#2837).
type (
SortService struct {
orderRepo repository.OrderRepository
}
SortDecoy struct {
orderRepo *LocalThing
}
)
func (s *SortService) Release(id string) error {
return s.orderRepo.DeleteItem(id)
}

View file

@ -0,0 +1,14 @@
package services
import "github.com/example/grouped/internal/repository"
// CONTROL: plain declaration. This struct is the only class-like def in its
// `type_declaration`, so it gets its own Class scope under either capture
// granularity and resolves today.
type WaveService struct {
orderRepo repository.OrderRepository
}
func (s *WaveService) Release(id string) error {
return s.orderRepo.DeleteItem(id)
}

View file

@ -2027,3 +2027,132 @@ describe('Go interface-typed struct field dispatch (#2813)', () => {
expect(callsFromFileToFile()).toContain('picking.go:Queue → GetPickQueue@order_repo.go');
});
});
// ---------------------------------------------------------------------------
// #2837 — grouped `type (...)` declarations collapse N types into ONE Class
// scope.
//
// `languages/go/query.ts` captures `@scope.class` on the `type_declaration`,
// not on the `type_spec`. An idiomatic grouped declaration is therefore a
// SINGLE capture owning every struct in the block, and
// `buildWorkspaceResolutionIndex` keeps only the FIRST class-like def per Class
// scope (`workspace-index.ts:156-164`). Every struct after the first has no
// `classScopeByDefId` entry, so `typeOfMemberOnClass` cannot find its fields,
// the Case 0 compound-receiver fold declines, and every field-receiver call
// site in that file emits ZERO edges — silently, and independently of file
// size. That is the per-file split #2837 reported and #2829 could not explain.
//
// Not caught before because ZERO of this repo's 115 Go fixture files used a
// grouped `type (...)` block, including #2829's own fixture.
describe('Go grouped type declaration scoping (#2837)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-grouped-type-decl'), () => {});
}, 120000);
/** Both ends file-qualified: three services declare a method named `Release`,
* so a target-only row cannot tell which file resolved. */
const callsFromFileToFile = (): string[] =>
getRelationships(result, 'CALLS').map(
(e) =>
`${e.sourceFilePath.split('/').slice(-1)[0]}:${e.source} → ` +
`${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}`,
);
const implementsEdges = (): string[] => edgeSet(getRelationships(result, 'IMPLEMENTS'));
// Control: the plain declaration resolves today and must keep resolving.
it('resolves a field receiver whose struct is declared plainly', () => {
expect(callsFromFileToFile()).toContain('wave_service.go:Release → DeleteItem@order_repo.go');
});
// The headline defect: identical field shape, declared SECOND in a grouped
// block, currently emits nothing at all.
it('resolves a field receiver whose struct is declared second in a grouped block', () => {
expect(callsFromFileToFile()).toContain('pick_service.go:Release → DeleteItem@order_repo.go');
});
// Order control. If only the first-declared struct resolves, the fix is
// order-luck rather than a fix.
it('resolves a field receiver whose struct is declared first in a grouped block', () => {
expect(callsFromFileToFile()).toContain('sort_service.go:Release → DeleteItem@order_repo.go');
});
// Equality, not mere presence: a one-sided fix fails here.
it('emits the same field-receiver edges for plain and grouped declarations', () => {
const rows = callsFromFileToFile();
const forFile = (f: string): string[] =>
rows
.filter((r) => r.startsWith(`${f}:`))
.map((r) => r.slice(f.length + 1))
.sort();
expect(forFile('pick_service.go')).toEqual(forFile('wave_service.go'));
expect(forFile('sort_service.go')).toEqual(forFile('wave_service.go'));
});
// The field-binding-collision half. While the grouped structs share ONE Class
// scope they also share one name-keyed `typeBindings` map, so `orderRepo`
// declared by `Decoy` as `*LocalThing` can type `PickService.orderRepo`.
// A fix that only made `workspace-index` map every class-like def would leave
// this map shared and would fail this row — which is why it is not the fix.
it('does not type a grouped struct field from a sibling struct of the same name', () => {
expect(callsFromFileToFile()).not.toContain(
'pick_service.go:Release → DeleteItem@pick_service.go',
);
});
// Grouped INTERFACE declarations collapse the same way. This row is also what
// discriminates the `tree-sitter-queries.ts` half of the fix: with only
// `languages/go/query.ts` re-anchored, MetricSink has a scope but still no
// graph NODE, so its implementor edge cannot exist.
it('detects implementors of both interfaces in a grouped interface block', () => {
expect(implementsEdges()).toContain('AuditWriter → AuditSink');
expect(implementsEdges()).toContain('MetricWriter → MetricSink');
});
// The node-level symptom, asserted directly for STRUCTS rather than only
// inferred from the interface row above. Before the fix the whole grouped
// block collapsed to one node and `PickService` was absent from the inventory
// entirely — `impact("PickService")` would have returned a clean, wrong zero.
it('emits a graph node for every struct in a grouped block', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('WaveService'); // plain — control
expect(structs).toContain('Decoy'); // grouped, first
expect(structs).toContain('PickService'); // grouped, second
expect(structs).toContain('SortService'); // grouped, first (reverse-order file)
expect(structs).toContain('SortDecoy'); // grouped, second (reverse-order file)
});
// Anchoring the captures on `type_spec` moved the node the class extractor and
// the doc-comment extractor are handed. Both had to be taught the new shape,
// and NEITHER is covered by the edge assertions above — the first pass of this
// change silently dropped both properties from every Go type while all seven
// rows above stayed green (#2843 review).
const typeProps = (label: 'Struct' | 'Interface', name: string): Record<string, unknown> =>
getNodesByLabelFull(result, label).find((n) => n.name === name)?.properties ?? {};
it('keeps the package-qualified name on every Go type', () => {
expect(typeProps('Struct', 'WaveService').qualifiedName).toBe('services.WaveService'); // plain
expect(typeProps('Struct', 'PickService').qualifiedName).toBe('services.PickService'); // grouped, 2nd
expect(typeProps('Interface', 'MetricSink').qualifiedName).toBe('repository.MetricSink'); // grouped iface, 2nd
});
it('keeps the godoc description on every Go type', () => {
expect(typeProps('Struct', 'WaveService').description).toBeTruthy(); // plain
expect(typeProps('Struct', 'PickService').description).toBeTruthy(); // grouped, 2nd
expect(typeProps('Interface', 'OrderRepository').description).toBeTruthy(); // plain interface
});
// Members must attribute to the struct that actually declares them. The owner
// walk took the FIRST `type_spec` of the declaration, so before the fix every
// field and method of a grouped block was filed under its first struct — and
// the two same-named `orderRepo` fields minted one id, dropping the second.
it('attributes grouped-block members to their own struct', () => {
const props = getRelationships(result, 'HAS_PROPERTY').map((e) => `${e.source}.${e.target}`);
expect(props).toContain('PickService.orderRepo');
expect(props).toContain('Decoy.orderRepo');
const methods = getRelationships(result, 'HAS_METHOD').map((e) => `${e.source}.${e.target}`);
expect(methods).toContain('MetricSink.Observe');
expect(methods).not.toContain('AuditSink.Observe');
});
});

View file

@ -123,8 +123,16 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 43 -> 44 for #2842's TypeScript heritage capture (interface and
// abstract-class `@reference.inherits`), which is parse-time emission and so
// cannot be served from a v43 warm cache.
it('pins SCHEMA_BUMP to 44 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(44);
// Moved 44 -> 45 for #2837 (Go struct/interface captures re-anchored from
// `type_declaration` to `type_spec`). This branch first took 44 and COLLIDED
// with #2842 above, which merged first — the ninth entry in the ledger and the
// third EXACT clash. Note what this pin could and could not do: it cannot
// detect the tie (both branches asserted `toBe(44)`, which passes when main is
// already 44); only the merge-time diff against origin/main surfaced it. What
// the pin DOES do is fail loudly the moment the constant and this expectation
// drift apart, which is what forces the re-check to happen at all.
it('pins SCHEMA_BUMP to 45 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(45);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {

View file

@ -104,6 +104,61 @@ describe('leading-doc descriptionExtractor — behavior per comment family', ()
expect(d).toContain('GOMARK');
});
// Go TYPE declarations, not just functions (#2837). Every Go row above uses
// `function_declaration`/`definition.function`, which anchors on its own
// declaration node and therefore carries the doc comment as a previous
// sibling. Types do not: #2837 re-anchored their captures onto the `type_spec`,
// whose `previousNamedSibling` is null because the comment belongs to the
// enclosing `type_declaration`. That silently removed the description from
// every Go struct and interface, and this file — the one place that checks doc
// extraction across all languages — was structurally incapable of noticing.
it('Go godoc on a struct declaration', () => {
const d = describeFromProvider(
SupportedLanguages.Go,
Go,
`package p\n// Wave carries orders, marker GOSTRUCT.\ntype Wave struct { n int }`,
'type_spec',
'definition.struct',
'Struct',
'Wave',
);
expect(d).toContain('GOSTRUCT');
});
it('Go godoc on an interface declaration', () => {
const d = describeFromProvider(
SupportedLanguages.Go,
Go,
`package p\n// Sink accepts events, marker GOIFACE.\ntype Sink interface { Emit() }`,
'type_spec',
'definition.interface',
'Interface',
'Sink',
);
expect(d).toContain('GOIFACE');
});
// A type declared inside a grouped `type (...)` block with its OWN doc comment
// must get that comment. Here the doc IS a previous sibling of the `type_spec`
// (verified against the real grammar), so this exercises the direct path while
// the two rows above exercise the `wrapperNodeTypes` fallback.
//
// The documented type is deliberately the FIRST spec in the block: the helper
// resolves `descendantsOfType(nodeType)[0]`, so a doc on a later spec would be
// read off the wrong node and the row would assert nothing.
it('Go godoc on a type inside a grouped declaration', () => {
const d = describeFromProvider(
SupportedLanguages.Go,
Go,
`package p\ntype (\n\t// First holds state, marker GOGROUPED.\n\tFirst struct { a int }\n\n\tSecond struct { b int }\n)`,
'type_spec',
'definition.struct',
'Struct',
'First',
);
expect(d).toContain('GOGROUPED');
});
it('Rust /// doc comment', () => {
const d = describeFromProvider(
SupportedLanguages.Rust,

View file

@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import Go from 'tree-sitter-go';
import {
goPackageDir,
inferGoPackageName,
} from '../../../../src/core/ingestion/languages/go/package-clause.js';
import { getGoParser, getGoScopeQuery } from '../../../../src/core/ingestion/languages/go/query.js';
import { GO_QUERIES } from '../../../../src/core/ingestion/tree-sitter-queries.js';
/**
* #2837. Both Go package-bucketing passes used to carry their own copy of
*
* sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m)
*
* whose `m` flag matches the first `package <ident>` line ANYWHERE in the file,
* comment bodies included. The two rows marked "regression" below were measured
* returning the decoy name against that expression; everything else is a
* behaviour-preservation control, because a stricter resolver that rejected a
* real-world header would be a worse bug than the one being fixed.
*/
describe('inferGoPackageName (#2837)', () => {
it('reads a plain package clause', () => {
expect(inferGoPackageName('package services\n\nfunc f() {}\n')).toBe('services');
});
it('reads past a build constraint', () => {
expect(inferGoPackageName('//go:build linux\n\npackage services\n')).toBe('services');
});
it('reads past a line-comment doc block', () => {
expect(inferGoPackageName('// Package services does things.\npackage services\n')).toBe(
'services',
);
});
// REGRESSION: measured as "legacy_notes" before the fix.
it('ignores a package line inside a block comment', () => {
const src = '/*\npackage legacy_notes kept for history\n*/\npackage services\n';
expect(inferGoPackageName(src)).toBe('services');
});
// REGRESSION: measured as "helper" before the fix.
it('ignores an indented package line inside a block comment', () => {
const src = '/*\n package helper old name\n*/\npackage services\n';
expect(inferGoPackageName(src)).toBe('services');
});
it('reads past several comments on one line', () => {
expect(inferGoPackageName('/* a */ /* b */ package services\n')).toBe('services');
});
it('reads past a byte-order mark and CRLF line endings', () => {
expect(inferGoPackageName('//go:build linux\r\n\r\npackage services\r\n')).toBe('services');
});
it('is not fooled by a package line inside a later raw string literal', () => {
const src = 'package services\n\nconst tmpl = `\npackage other\n`\n';
expect(inferGoPackageName(src)).toBe('services');
});
// Go separates tokens by any whitespace, so this is legal and tree-sitter
// parses it without error. A stricter matcher would drop the file from BOTH
// Go cross-file passes (#2843 review).
it('accepts a newline between the keyword and the package name', () => {
expect(inferGoPackageName('package\nmain\n\nfunc f() {}\n')).toBe('main');
});
it('accepts CR-only line endings', () => {
expect(inferGoPackageName('//go:build linux\r\rpackage services\r')).toBe('services');
});
// `gorun`-style scripts. Not legal Go, but the regex this replaced skipped it
// via `/m`, so rejecting it would be a silent regression rather than a
// principled tightening.
it('reads past a leading shebang line', () => {
expect(inferGoPackageName('#!/usr/bin/env gorun\n\npackage main\n')).toBe('main');
});
it('still stops on a # that is not a first-line shebang', () => {
expect(inferGoPackageName('// doc\n#!/usr/bin/env gorun\npackage main\n')).toBeNull();
});
it('returns null when the first real token is not a package clause', () => {
expect(inferGoPackageName('func main() {}\n')).toBeNull();
});
it('returns null for empty input', () => {
expect(inferGoPackageName('')).toBeNull();
});
it('returns null for an unterminated block comment', () => {
expect(inferGoPackageName('/* never closed\npackage services\n')).toBeNull();
});
it('returns null for a line comment running to EOF', () => {
expect(inferGoPackageName('// only a comment')).toBeNull();
});
});
describe('goPackageDir', () => {
it('returns the containing directory', () => {
expect(goPackageDir('internal/services/pick_service.go')).toBe('internal/services');
});
it('normalizes Windows separators', () => {
expect(goPackageDir('internal\\services\\pick_service.go')).toBe('internal/services');
});
it('returns an empty string for a repo-root file', () => {
expect(goPackageDir('main.go')).toBe('');
});
});
/**
* The scope/def range contract, asserted directly (#2843 review).
*
* #2837 moved five Go captures from the `type_declaration` onto the `type_spec`.
* That contract was previously observable only as 70 changed digests in the
* captures golden, where a future edit that moved ranges again would look
* identical. These rows state it outright, and pin the LOCKSTEP requirement:
* the scope capture and the def capture must name the SAME node, or the def is
* larger than its own scope and nothing is owned.
*/
describe('Go type capture anchoring (#2837)', () => {
// The SHIPPED queries, not copies of them. Inlining the patterns here would
// make every row below assert against the test's own string, so reverting the
// real re-anchor would leave them green — the exact regression they exist to
// catch. `getGoScopeQuery()` is the memoized `GO_SCOPE_QUERY`; `GO_QUERIES` is
// what the parse worker runs.
const goQueries = new Parser.Query(Go as Parameters<Parser['setLanguage']>[0], GO_QUERIES);
const nodesNamed = (src: string, query: Parser.Query, name: string) =>
query
.captures(getGoParser().parse(src).rootNode)
.filter((c) => c.name === name)
.map((c) => c.node);
const classScopes = (src: string) => nodesNamed(src, getGoScopeQuery(), 'scope.class');
it('anchors the class scope on the type_spec, one per declared type', () => {
const nodes = classScopes(
'package p\ntype ( A struct{ x int }\n B struct{ y int } )\ntype C struct{}\n',
);
expect(nodes.map((n) => n.type)).toEqual(['type_spec', 'type_spec', 'type_spec']);
expect(nodes.map((n) => n.childForFieldName('name')?.text)).toEqual(['A', 'B', 'C']);
});
it('starts the scope range at the type name, not the `type` keyword', () => {
const [scope] = classScopes('package p\ntype C struct{}\n');
expect(scope!.startIndex).toBe(scope!.childForFieldName('name')!.startIndex);
});
// The lockstep invariant, checked ACROSS the two shipped queries: the scope
// capture in languages/go/query.ts and the definition capture in
// tree-sitter-queries.ts must name the same node. Moving one without the other
// made the def strictly larger than its own scope and deleted every Go
// field-receiver edge, plain declarations included.
it('anchors scope and definition on the same node', () => {
const src = 'package p\ntype ( A struct{ x int }\n B struct{ y int } )\n';
const defs = nodesNamed(src, goQueries, 'definition.struct');
expect(defs.map((d) => d.type)).toEqual(['type_spec', 'type_spec']);
expect(defs.map((d) => d.startIndex)).toEqual(classScopes(src).map((s) => s.startIndex));
});
it('emits no class scope for an alias or a named non-struct type', () => {
expect(classScopes('package p\ntype Alias = Other\ntype Named int\n')).toHaveLength(0);
});
});

View file

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../src/core/logger.js', () => ({
logger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
},
}));
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { logger } from '../../../../src/core/logger.js';
import { populateGoWorkspaceOwners } from '../../../../src/core/ingestion/languages/go/method-owners.js';
/**
* #2837. A Go file whose package clause cannot be resolved is dropped from
* method-owner resolution entirely — its methods can never attach to a struct
* declared in a sibling file. That used to be a bare `continue` with no trace,
* the same false-safe silence #2813 was filed about.
*
* The warning is bounded on purpose: one line per analyze, naming at most five
* paths, never one line per file.
*/
describe('populateGoWorkspaceOwners no-package-clause reporting (#2837)', () => {
beforeEach(() => {
vi.mocked(logger.warn).mockClear();
});
it('says nothing when every file has a package clause', () => {
populateGoWorkspaceOwners([parsed('a/svc.go')], {
fileContents: new Map([['a/svc.go', 'package a\n']]),
});
expect(vi.mocked(logger.warn)).not.toHaveBeenCalled();
});
it('emits exactly one warning naming the file it dropped', () => {
populateGoWorkspaceOwners([parsed('a/svc.go'), parsed('a/broken.go')], {
fileContents: new Map([
['a/svc.go', 'package a\n'],
['a/broken.go', 'func Orphan() {}\n'],
]),
});
expect(vi.mocked(logger.warn)).toHaveBeenCalledTimes(1);
const [payload] = vi.mocked(logger.warn).mock.calls[0] as [
{ skippedFiles: number; sample: string[] },
];
expect(payload.skippedFiles).toBe(1);
expect(payload.sample).toEqual(['a/broken.go']);
});
// A file whose header comment happens to contain a `package` line used to be
// bucketed under THAT name, silently isolating it. It must now resolve to its
// real package and therefore produce no warning at all.
it('does not warn for a file whose comment mentions another package', () => {
populateGoWorkspaceOwners([parsed('a/svc.go')], {
fileContents: new Map([
['a/svc.go', '/*\npackage legacy_notes kept for history\n*/\npackage a\n'],
]),
});
expect(vi.mocked(logger.warn)).not.toHaveBeenCalled();
});
it('caps the sample at five paths while reporting the true total', () => {
const files = Array.from({ length: 9 }, (_, i) => parsed(`a/f${i}.go`));
const contents = new Map(files.map((f) => [f.filePath, 'not go source\n']));
populateGoWorkspaceOwners(files, { fileContents: contents });
expect(vi.mocked(logger.warn)).toHaveBeenCalledTimes(1);
const [payload] = vi.mocked(logger.warn).mock.calls[0] as [
{ skippedFiles: number; sample: string[] },
];
expect(payload.skippedFiles).toBe(9);
expect(payload.sample).toHaveLength(5);
});
});
function parsed(filePath: string): ParsedFile {
const localDef: SymbolDefinition = {
nodeId: `def:${filePath}`,
filePath,
type: 'Function',
qualifiedName: 'Noop',
};
return {
filePath,
moduleScope: `module:${filePath}`,
scopes: [],
parsedImports: [],
localDefs: [localDef],
referenceSites: [],
};
}

View file

@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const emit = vi.hoisted(() => vi.fn());
// Only `createLogger` is mocked: the module under test imports nothing else
// from the logger. The diagnostic is opt-in via `debugEnvVar` and emits at
// `debug`, so the assertions drive that child logger.
vi.mock('../../../src/core/logger.js', () => ({
createLogger: () => ({
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: emit,
trace: vi.fn(),
fatal: vi.fn(),
isLevelEnabled: () => true,
}),
}));
import { logUnresolvedReceiverFiles } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
/**
* #2837 / #2843 review. This diagnostic exists so a reporter can see WHICH file
* lost its receivers. It must therefore count the same drops
* `summarizeUnresolvedReceivers` counts — the first version omitted both of that
* function's guards and its top-offender list was led by files that lost
* nothing (measured on this repo: the top three were all test files).
*/
describe('logUnresolvedReceiverFiles (#2843 review)', () => {
beforeEach(() => emit.mockClear());
const drop = (filePath: string, over: Partial<ResolutionOutcome> = {}): ResolutionOutcome =>
({
kind: 'suppressed',
phase: 'receiver-bound-calls',
reason: 'receiver-unresolved',
filePath,
name: 'DoWork',
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
candidateIds: [],
...over,
}) as ResolutionOutcome;
const payload = (): {
totalSites: number;
filesAffected: number;
topFiles: { filePath: string; sites: number }[];
} => emit.mock.calls[0]![0] as never;
it('says nothing when there are no receiver-unresolved drops', () => {
logUnresolvedReceiverFiles([]);
expect(emit).not.toHaveBeenCalled();
});
it('counts in-program call drops per file', () => {
logUnresolvedReceiverFiles([drop('a.go'), drop('a.go'), drop('b.go')]);
expect(payload().topFiles).toEqual([
{ filePath: 'a.go', sites: 2 },
{ filePath: 'b.go', sites: 1 },
]);
expect(payload().totalSites).toBe(3);
});
// Guard 1, mirroring unresolved-receivers.ts: property reads/writes are not
// call sites. Measured there at 25 of 124 drops on the fixture corpus.
it('excludes non-call sites', () => {
logUnresolvedReceiverFiles([
drop('a.go'),
drop('a.go', { siteKind: 'read' }),
drop('a.go', { siteKind: 'write' }),
]);
expect(payload().topFiles).toEqual([{ filePath: 'a.go', sites: 1 }]);
});
// Guard 2: an external-rooted receiver (`fmt.Println`) reaches code this index
// does not contain, so no edge was ever possible and nothing was lost.
it('excludes external-rooted drops', () => {
logUnresolvedReceiverFiles([drop('a.go'), drop('a.go', { receiverOrigin: 'external' })]);
expect(payload().topFiles).toEqual([{ filePath: 'a.go', sites: 1 }]);
});
// `unknown` origin counts WITH in-program: assuming a completeness we cannot
// demonstrate is the unsafe direction.
it('counts unknown-origin drops', () => {
logUnresolvedReceiverFiles([drop('a.go', { receiverOrigin: 'unknown' })]);
expect(payload().topFiles).toEqual([{ filePath: 'a.go', sites: 1 }]);
});
it('ranks by count then by code units, and caps the sample at ten files', () => {
const outcomes = [
...Array.from({ length: 12 }, (_, i) => drop(`f${String(i).padStart(2, '0')}.go`)),
drop('zz.go'),
drop('zz.go'),
];
logUnresolvedReceiverFiles(outcomes);
expect(payload().topFiles).toHaveLength(10);
expect(payload().topFiles[0]).toEqual({ filePath: 'zz.go', sites: 2 });
// Ties break by code-unit order, so the survivors of the cap are stable.
expect(
payload()
.topFiles.slice(1)
.map((f) => f.filePath),
).toEqual([
'f00.go',
'f01.go',
'f02.go',
'f03.go',
'f04.go',
'f05.go',
'f06.go',
'f07.go',
'f08.go',
]);
// The total is the true count, not the capped sample.
expect(payload().totalSites).toBe(14);
expect(payload().filesAffected).toBe(13);
});
});