diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 07bc49d17..fe25691f5 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,7 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* 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", diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/go.ts b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts index 58dade9fa..0a32de2f6 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/go.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index e7a25e82f..915e241a3 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts index d684340d3..fad2ee8f1 100644 --- a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -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; type MutableMethodSet = Map; @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts index 53ea352b0..329c94447 100644 --- a/gitnexus/src/core/ingestion/languages/go/method-owners.ts +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -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 }, ): void { const filesByPackage = new Map(); + // 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); -} diff --git a/gitnexus/src/core/ingestion/languages/go/package-clause.ts b/gitnexus/src/core/ingestion/languages/go/package-clause.ts new file mode 100644 index 000000000..657a40bad --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/package-clause.ts @@ -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 ` — 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); +} diff --git a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts index bcb0687dd..bb5c4cab8 100644 --- a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts @@ -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(); 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: ScopeId, diff --git a/gitnexus/src/core/ingestion/languages/go/query.ts b/gitnexus/src/core/ingestion/languages/go/query.ts index 885c19df7..b782a3503 100644 --- a/gitnexus/src/core/ingestion/languages/go/query.ts +++ b/gitnexus/src/core/ingestion/languages/go/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts index 11e7d7f61..9c26a673d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts @@ -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): { +function rankAndCap( + counts: Map, + 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 { + 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 | undefined; +const dropLog = (): ReturnType => + (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(); + 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)', + ); +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d4ce4860f..765e8492b 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index a1832335e..64eab074f 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -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, + }; } } } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 5738a504d..b2fa92ab9 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 = {}; 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 diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 16fab259f..63654ffb8 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 3065bfa0d..22c8693b4 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -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" } } diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod new file mode 100644 index 000000000..57cb24d59 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/go.mod @@ -0,0 +1,3 @@ +module github.com/example/grouped + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go new file mode 100644 index 000000000..43aa842a5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/interfaces.go @@ -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 + } +) diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go new file mode 100644 index 000000000..f53a4e9a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/order_repo.go @@ -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 } diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go new file mode 100644 index 000000000..e4672ff5a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/repository/sinks.go @@ -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 } diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go new file mode 100644 index 000000000..66245a675 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/pick_service.go @@ -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) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go new file mode 100644 index 000000000..4a3ecf98e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/sort_service.go @@ -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) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go new file mode 100644 index 000000000..383c0946d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-grouped-type-decl/internal/services/wave_service.go @@ -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) +} diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 3f92964ae..870b486fd 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -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 => + 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'); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c253070fb..124529999 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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)', () => { diff --git a/gitnexus/test/unit/leading-doc-description-all-languages.test.ts b/gitnexus/test/unit/leading-doc-description-all-languages.test.ts index 8a2165350..6bc3290c6 100644 --- a/gitnexus/test/unit/leading-doc-description-all-languages.test.ts +++ b/gitnexus/test/unit/leading-doc-description-all-languages.test.ts @@ -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, diff --git a/gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts b/gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts new file mode 100644 index 000000000..9c4f8db3d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-package-clause.test.ts @@ -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 ` 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[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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts b/gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts new file mode 100644 index 000000000..e4b4713f7 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts @@ -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: [], + }; +} diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts new file mode 100644 index 000000000..a13186f46 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/unresolved-receiver-files.test.ts @@ -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 => + ({ + 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); + }); +});