@orangepro/orangepro-mcp 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,33 +2,52 @@
2
2
 
3
3
  **Find the behaviors your tests miss. Generate grounded tests that actually run.**
4
4
 
5
- `opro` builds a knowledge graph from your local checkout, maps every behavior in your code, shows which ones are tested and which aren't, and generates integration-level tests grounded in real symbols — not hallucinated imports. Runs as a CLI and an MCP server.
5
+ `opro` builds a knowledge graph from your local checkout, maps every behavior in your code, shows which ones are tested and which aren't, and generates integration-level tests grounded in real symbols — not hallucinated imports. It runs as a CLI and a local stdio MCP server.
6
+
7
+ Install the target repository's dependencies first, then run OrangePro from that repository:
6
8
 
7
9
  ```bash
8
- npx @orangepro/mcp-server
9
10
  cd /path/to/your/repo
10
- opro
11
+ npm install # or pnpm install / bun install / the repository's package manager
12
+
13
+ # Optional: enables AI candidate links, candidate flows, and test generation.
14
+ export ANTHROPIC_API_KEY="..." # or OPENAI_API_KEY / OLLAMA_BASE_URL
15
+
16
+ npx -y @orangepro/mcp-server@latest start . --prompt-version v5
17
+ open .orangepro/behavior-coverage.html
11
18
  ```
12
19
 
13
- That's it. You get:
20
+ With no model key, the same command still performs deterministic analysis, renders the report, and dynamically proves eligible behaviors using existing tests. With a key, it also discovers AI candidate flows and drafts grounded tests for the highest-risk gaps. AI output never changes evidence tiers; only the mutation-kill oracle can mint **Dynamically Proven**.
21
+
22
+ The command writes:
14
23
 
15
24
  ```
16
25
  .orangepro/
17
- ├── behavior-coverage.html ← open this: interactive gap report
26
+ ├── behavior-coverage.html ← open this: system map, risks, flows, behaviors
27
+ ├── graph.json ← deterministic evidence graph
28
+ ├── COVERAGE_REPORT.md ← coverage and gap summary
18
29
  ├── rtm.md ← requirements traceability matrix
19
- └── evidence-pack.json machine-readable metadata export
30
+ └── ai/ candidate AI links/flows when a provider is configured
31
+
32
+ orangepro_generated/ ← contained generated tests; existing source files are untouched
20
33
  ```
21
34
 
35
+ The report opens on a **system map** of your repo — entry lanes (GraphQL/HTTP/Jobs) flowing into the services they reach, sized by traffic, colored by evidence tier, risk-ringed — identical on every run. Each rerun shows a **delta banner**: what changed since last run, or "No changes — identical graph, identical ranking." Every one of the ~N behaviors gets a plain-English description; every top risk gets a deterministic context line and a state-aware next step.
36
+
37
+ Run `opro export` when you want a machine-readable evidence pack.
38
+ <img width="895" height="960" alt="Screenshot 2026-07-08 at 1 18 01 AM" src="https://github.com/user-attachments/assets/73b8a812-2eab-43a1-8aed-4289545e630b" />
39
+
22
40
  ---
23
41
 
24
42
  ## Install
25
43
 
26
44
  ```bash
27
- # No install needed (npx)
28
- npx @orangepro/mcp-server
45
+ # No install needed: run the full local workflow in the current repository
46
+ npx -y @orangepro/mcp-server@latest start . --prompt-version v5
29
47
 
30
48
  # Or global install
31
49
  npm install -g @orangepro/orangepro-mcp
50
+ opro start . --prompt-version v5
32
51
 
33
52
  # Or from source
34
53
  git clone https://github.com/OrangeproAI/orangepro-mcp.git
@@ -89,7 +108,6 @@ Add to your client's MCP config:
89
108
 
90
109
  | Client | Config location |
91
110
  | --- | --- |
92
- |--------|----------------|
93
111
  | Claude Code | `.mcp.json` or `~/.claude.json` |
94
112
  | Cursor | `~/.cursor/mcp.json` or Settings → MCP |
95
113
  | Codex | Config printed by `opro agent --client codex` or `npx -y @orangepro/mcp-server@latest agent --client codex` |
@@ -144,6 +162,7 @@ opro rtm # traceability matrix
144
162
  opro export # metadata-only evidence pack
145
163
  opro mcp # run as MCP server (stdio)
146
164
  opro doctor # what evidence to add next
165
+ opro doctor --proof # explain why dynamic proof could not close
147
166
  opro coverage # ingest runtime coverage
148
167
  ```
149
168
 
@@ -164,11 +183,13 @@ Each generated test includes:
164
183
  - **Run hints** — where to write it, how to run it
165
184
  - **Scenario bucket + technique** — what failure mode it targets and how
166
185
 
186
+ If the environment can't run tests yet (dependencies not installed, runner unconfigured), rejected drafts are kept as **Manual tests** — scenario, Given/When/Then steps, synthetic test data, and expected outcome in plain English, with the exact blocker named. Install dependencies and re-run `opro start` to turn them into runnable tests. Runnable tests always replace Manual tests for the same behavior; the two are never mixed.
187
+
167
188
  ---
168
189
 
169
190
  ## Test categories
170
191
 
171
- Generation is evidence-gated. A category is produced only when the graph has supporting evidence — never padded with generic filler. These are the public local generation buckets. The broader concern taxonomy used by planning prompts is not a public coverage taxonomy and does not change report tiers.
192
+ Generation is evidence-gated. A category is produced only when the graph has supporting evidence — never padded with generic filler. These are the local generation buckets. The report additionally shows each risk's **applicable testing categories** (contract, boundary limits, integration flow, state lifecycle, failure recovery, …), derived deterministically from graph facts — covered categories from real attached tests render normally; the rest render locked, meaning "warranted here, generated on the platform," never "hidden tests exist." Neither taxonomy changes evidence tiers.
172
193
 
173
194
  | Category | What it targets |
174
195
  |----------|-----------------|
@@ -189,11 +210,14 @@ Every behavior gets exactly one tier. Nothing is labeled "tested" on faith.
189
210
  |------|---------------|------------------|
190
211
  | **Dynamically Proven** | A real test kills a targeted mutant of this behavior | `opro prove` after writing/running a test |
191
212
  | **Runtime-covered** | Coverage tool executed this code | `opro start --generate-coverage` |
192
- | **Statically Linked** | Import/name/structural match links a test to this code | Automatic during analysis |
213
+ | **Statically Linked** | A test **imports and calls** this code — a hard structural link | Automatic during analysis |
214
+ | **Unconfirmed Candidate** | A lexically similar test file exists, but nothing links it — a lead, **not evidence** | Automatic; upgrade it by writing the linking test |
193
215
  | **No Signal** | Nothing tests this behavior yet | — |
194
216
 
195
217
  > **"Dynamically Proven 0" is normal on first run.** Static analysis always runs. Dynamic proof requires running tests against targeted mutations. That's the trust model — nothing is Dynamically Proven until a real test kills a real mutant.
196
218
 
219
+ When runtime coverage is available, `opro start` also compares Runtime-covered and Dynamically Proven behaviors over the same deterministic denominator. It never compares source-line coverage with behavior proof or folds off-denominator proofs into that percentage.
220
+
197
221
  ---
198
222
 
199
223
  ## Language support
@@ -265,14 +289,17 @@ OrangePro separates **analysis** (what your code does) from **proof** (whether t
265
289
  | **Generate** | Grounded tests for top gaps, per-behavior | Yes (BYOK) |
266
290
  | **Prove** | Mutation-kill oracle confirms test actually breaks if behavior changes | No |
267
291
 
292
+ Reruns are cache-accelerated: unchanged files skip re-parsing, BYOK stages don't re-spend tokens on unchanged inputs, and proof certificates persist in a local ledger until the certified file changes. Upgrading the tool auto-invalidates caches.
293
+
268
294
  ---
269
295
 
270
296
  ## Privacy
271
297
 
272
298
  - **No stored source.** Reads code in-process. Never uploads to an OrangePro server.
273
- - **No source mutation.** Never edits your existing files. Writes metadata to `.orangepro/`.
299
+ - **No existing-source mutation.** Never edits existing source or test files. Writes metadata to `.orangepro/`; keyed auto-drive may write new, reviewable tests under `orangepro_generated/`.
274
300
  - **Metadata-only exports.** File paths, names, hashes, scores — not raw source.
275
301
  - **Your keys stay yours.** Read from env at call time, never persisted.
302
+ - **BYOK is direct.** When AI lanes are enabled, grounded code context is sent directly to the model provider you configure; OrangePro's hosted service is not in that path.
276
303
 
277
304
  ---
278
305
 
@@ -1534,6 +1534,40 @@ export function analyzeRepo(root, opts = {}) {
1534
1534
  const symId = `sym:${targetRel}#${name}`;
1535
1535
  return codeSymbolIds.has(symId) ? symId : null;
1536
1536
  };
1537
+ /**
1538
+ * ALL package method symbols whose bare name is `bareName` — receiver-qualified
1539
+ * `Recv.M` hits plus bare-named ones (underivable receiver). Go method symbols
1540
+ * are minted qualified (engine `goReceiverBaseName`), so a bare test callee maps
1541
+ * back through this. Same-named FREE functions never appear (kind-filtered):
1542
+ * the mutator's receiver-exact `--recv` match means a free fn can never be
1543
+ * mutated in a method attempt.
1544
+ */
1545
+ const goPackageMethodHits = (dir, bareName) => {
1546
+ const suffix = `.${bareName}`;
1547
+ const hits = [];
1548
+ for (const f of goFilesByDir.get(dir) ?? []) {
1549
+ if (isNonProductFile(f))
1550
+ continue;
1551
+ for (const n of symbolsByFile.get(f) ?? []) {
1552
+ if ((n === bareName || n.endsWith(suffix)) && symbolKind(f, n) === "method")
1553
+ hits.push({ file: f, qualified: n });
1554
+ }
1555
+ }
1556
+ return hits;
1557
+ };
1558
+ /**
1559
+ * Receiver type pinned from a constructor's DECLARED result: `p := New(...)` →
1560
+ * New's single `T`/`*T` result base name (engine `goCtorResults`, structurally
1561
+ * capturable results only). Undefined — never a guess — when the ctor is not
1562
+ * package-unique or its result was not capturable (multi-return, interface,
1563
+ * qualified, generic): receiver pinning must fail closed.
1564
+ */
1565
+ const goCtorResultType = (dir, ctorName) => {
1566
+ const ctorFile = uniqueGoPackageSymbol(dir, ctorName);
1567
+ if (!ctorFile)
1568
+ return undefined;
1569
+ return nonTsStructureByFile.get(ctorFile)?.structure.goCtorResults?.[ctorName];
1570
+ };
1537
1571
  const eligiblePythonSymbol = (targetRel, name) => {
1538
1572
  if (!targetRel || !(proofEligibleSymbolsByFile.get(targetRel) ?? []).includes(name))
1539
1573
  return null;
@@ -1554,13 +1588,38 @@ export function analyzeRepo(root, opts = {}) {
1554
1588
  const symId = `sym:${targetRel}#${name}`;
1555
1589
  return codeSymbolIds.has(symId) ? symId : null;
1556
1590
  };
1557
- const resolveGoProofTarget = (testRel, structure, qualifier, callee, shadowed) => {
1591
+ const resolveGoProofTarget = (testRel, structure, qualifier, callee, shadowed, receiverLocal, receiverCtor) => {
1558
1592
  if (!qualifier) {
1559
1593
  if (shadowed.has(callee) || structure.packageName?.endsWith("_test"))
1560
1594
  return null;
1561
1595
  return eligibleGoSymbol(uniqueGoPackageSymbol(dirOf(testRel), callee), callee);
1562
1596
  }
1563
1597
  const rootQualifier = qualifier.split(".")[0];
1598
+ // Receiver-local METHOD call `p.M()`: the qualifier `p` is a LOCAL by design
1599
+ // (declared `p := New(...)`), so this must be checked BEFORE the qualifier-shadow
1600
+ // guard (which exists to reject package-func names shadowed by locals, not receivers).
1601
+ // Resolve the bare callee among the package's receiver-qualified method symbols
1602
+ // (`Recv.M`):
1603
+ // - ONE method named M package-wide → take it (the #212 unique-name behavior;
1604
+ // the mutator's `--recv` exact match keeps the mutation on that receiver).
1605
+ // - MULTIPLE methods named M → pin the receiver TYPE from the constructor's
1606
+ // DECLARED result (`p := New(...)` → goCtorResultType, structurally provable
1607
+ // results only) and require exactly one `pin.M` hit; no provable pin → refuse
1608
+ // (the pre-pinning fail-closed behavior).
1609
+ // The pin SELECTS only — it never rejects the unique candidate (an interface- or
1610
+ // alias-returning ctor would name an abstraction, not a receiver, and a wrong
1611
+ // selection is fail-safe anyway: the frozen oracle only fails closed in the
1612
+ // survive direction). Callee and ctor both honor shadowing.
1613
+ if (receiverLocal && !shadowed.has(callee) && !structure.packageName?.endsWith("_test")) {
1614
+ const hits = goPackageMethodHits(dirOf(testRel), callee);
1615
+ if (hits.length === 1)
1616
+ return eligibleGoSymbol(hits[0].file, hits[0].qualified);
1617
+ const pinned = receiverCtor && !shadowed.has(receiverCtor) ? goCtorResultType(dirOf(testRel), receiverCtor) : undefined;
1618
+ if (!pinned)
1619
+ return null;
1620
+ const match = hits.filter((h) => h.qualified === `${pinned}.${callee}`);
1621
+ return match.length === 1 ? eligibleGoSymbol(match[0].file, match[0].qualified) : null;
1622
+ }
1564
1623
  if (shadowed.has(rootQualifier))
1565
1624
  return null;
1566
1625
  const binding = structure.imports.find((i) => i.local === rootQualifier && i.kind === "module");
@@ -1730,7 +1789,7 @@ export function analyzeRepo(root, opts = {}) {
1730
1789
  const testExternalId = `test:${testRel}`;
1731
1790
  for (const proof of structure.goProofCalls ?? []) {
1732
1791
  goProofAttempted++;
1733
- const symId = resolveGoProofTarget(testRel, structure, proof.qualifier, proof.callee, new Set(proof.shadowed));
1792
+ const symId = resolveGoProofTarget(testRel, structure, proof.qualifier, proof.callee, new Set(proof.shadowed), proof.receiverLocal, proof.receiverCtor);
1734
1793
  if (!symId)
1735
1794
  continue;
1736
1795
  const edgeKey = `${testExternalId}|${symId}`;
@@ -6,8 +6,15 @@ const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "options"
6
6
  // - computed router methods/paths are ignored.
7
7
  // That is intentional while Endpoint nodes are informational only and excluded
8
8
  // from the coverage denominator.
9
- const NEST_METHOD_DECORATOR = /@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*(?:(["'`])([^"'`]*)\2)?\s*\)(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
10
- const NEST_CONTROLLER_DECORATOR = /@Controller\s*\(\s*(?:(["'`])([^"'`]*)\1)?\s*\)\s*(?:export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
9
+ // Tolerates intermediate decorators (@UseGuards, @UseFilters, @HttpCode, …)
10
+ // between the route decorator and the method name — decorator-stacked NestJS
11
+ // controllers (Twenty: every route) previously produced ZERO endpoint contracts.
12
+ const NEST_METHOD_DECORATOR = /@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*\(\s*(?:(["'`])([^"'`]*)\2|\[\s*(["'`])([^"'`]*)\4\s*\])?\s*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
13
+ const CLASS_DECLARATION = /(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
14
+ const NEST_GQL_METHOD_DECORATOR = /@(Query|Mutation|Subscription|ResolveField)\s*\((?:[^()]|\([^()]*\))*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
15
+ const NEST_PROCESSOR_DECORATOR = /@Processor\s*\(\s*(?:(["'`])([^"'`]*)\1|[A-Za-z_$][A-Za-z0-9_$.]*)?\s*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
16
+ const NEST_PROCESS_METHOD_DECORATOR = /@Process\s*\((?:[^()]|\([^()]*\))*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
17
+ const NEST_CONTROLLER_DECORATOR = /@Controller\s*\(\s*(?:(["'`])([^"'`]*)\1)?\s*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
11
18
  const EXPRESS_ROUTER_CALL = /\b(?:router|app)\s*\.\s*(get|post|put|delete|patch|options|head|all)\s*\(\s*(["'`])([^"'`]*)\2\s*,\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?|\([^)]*\)\s*=>|async\s+\([^)]*\)\s*=>|function\s+[A-Za-z_$][A-Za-z0-9_$]*)/gi;
12
19
  const EXPRESS_ROUTE_CHAIN = /\b(?:router|app)\s*\.\s*route\s*\(\s*(["'`])([^"'`]*)\1\s*\)((?:\s*\.\s*(?:get|post|put|delete|patch|options|head|all)\s*\([^)]*\))+)/gi;
13
20
  const CHAINED_METHOD_CALL = /\.\s*(get|post|put|delete|patch|options|head|all)\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?|\([^)]*\)\s*=>|async\s+\([^)]*\)\s*=>|function\s+[A-Za-z_$][A-Za-z0-9_$]*)/gi;
@@ -17,6 +24,8 @@ export function extractBehaviorContracts(content, file) {
17
24
  return dedupeContracts([
18
25
  ...extractFileRouteContracts(content, file),
19
26
  ...extractNestContracts(content, file),
27
+ ...extractNestGraphqlContracts(content, file),
28
+ ...extractNestProcessorContracts(content, file),
20
29
  ...extractRouterContracts(content, file, EXPRESS_ROUTER_CALL, "express"),
21
30
  ...extractExpressRouteChains(content, file),
22
31
  ...extractRouterContracts(content, file, FASTIFY_CALL, "fastify")
@@ -52,8 +61,8 @@ function extractNestContracts(content, file) {
52
61
  const index = match.index ?? 0;
53
62
  const controller = nearestController(controllers, index);
54
63
  const method = httpMethod(match[1]);
55
- const routePath = joinRoutePaths(controller?.path ?? "", match[3] ?? "");
56
- const handler = match[4];
64
+ const routePath = joinRoutePaths(controller?.path ?? "", match[3] ?? match[5] ?? "");
65
+ const handler = match[6];
57
66
  contracts.push(makeContract({
58
67
  file,
59
68
  framework: "nestjs",
@@ -65,6 +74,70 @@ function extractNestContracts(content, file) {
65
74
  }
66
75
  return contracts;
67
76
  }
77
+ function extractNestGraphqlContracts(content, file) {
78
+ // GraphQL resolvers are first-class user-triggerable entry points. In
79
+ // NestJS-heavy monorepos (Twenty: 415 @Query/@Mutation methods vs 139 HTTP
80
+ // routes) skipping them left almost every user behavior without an Endpoint
81
+ // anchor, so static flows rooted at internal orphan methods instead.
82
+ if (!/@(Query|Mutation|Subscription)\s*\(/.test(content))
83
+ return [];
84
+ const resolvers = [...content.matchAll(CLASS_DECLARATION)].map((match) => ({
85
+ index: match.index ?? 0,
86
+ path: "",
87
+ name: match[1]
88
+ }));
89
+ if (resolvers.length === 0)
90
+ return [];
91
+ const contracts = [];
92
+ for (const match of content.matchAll(NEST_GQL_METHOD_DECORATOR)) {
93
+ const resolver = nearestController(resolvers, match.index ?? 0);
94
+ if (!resolver)
95
+ continue;
96
+ const opKind = match[1].toUpperCase();
97
+ if (opKind === "RESOLVEFIELD")
98
+ continue; // field resolvers are not user-triggerable operations
99
+ const handler = match[2];
100
+ contracts.push(makeContract({
101
+ file,
102
+ framework: "nestjs",
103
+ kind: "graphql_operation",
104
+ method: opKind,
105
+ path: `graphql:${handler}`,
106
+ handler,
107
+ controller: resolver.name
108
+ }));
109
+ }
110
+ return contracts;
111
+ }
112
+ function extractNestProcessorContracts(content, file) {
113
+ // Background jobs and crons are behaviors (June 27 definition: user- or
114
+ // system-triggerable, cross-layer, observable outcome). Anchoring them lets
115
+ // the flow walker show queue-driven chains instead of orphan roots.
116
+ const processors = [...content.matchAll(NEST_PROCESSOR_DECORATOR)].map((match) => ({
117
+ index: match.index ?? 0,
118
+ path: normalizeRoutePath(match[2] ?? ""),
119
+ name: match[3]
120
+ }));
121
+ if (processors.length === 0)
122
+ return [];
123
+ const contracts = [];
124
+ for (const match of content.matchAll(NEST_PROCESS_METHOD_DECORATOR)) {
125
+ const processor = nearestController(processors, match.index ?? 0);
126
+ if (!processor)
127
+ continue;
128
+ const handler = match[1];
129
+ contracts.push(makeContract({
130
+ file,
131
+ framework: "nestjs",
132
+ kind: "queue_processor",
133
+ method: "JOB",
134
+ path: `queue:${processor.path || processor.name}`,
135
+ handler,
136
+ controller: processor.name
137
+ }));
138
+ }
139
+ return contracts;
140
+ }
68
141
  function nearestController(controllers, index) {
69
142
  let current;
70
143
  for (const controller of controllers) {
@@ -111,7 +184,7 @@ function makeContract(input) {
111
184
  return {
112
185
  id: `endpoint:${slugify(`${input.method}-${input.path}-${input.file}-${input.handler ?? ""}`)}`,
113
186
  title,
114
- kind: "http_endpoint",
187
+ kind: input.kind ?? "http_endpoint",
115
188
  framework: input.framework,
116
189
  method: input.method,
117
190
  path: input.path,
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  /**
2
3
  * Phase 5.4.2 — persistent PARSE cache.
3
4
  *
@@ -54,7 +55,20 @@
54
55
  // Swift protocol methods, and Rust trait signatures while dropping Rust aliases.
55
56
  // v16: symbol extraction now carries source line spans for runtime coverage
56
57
  // report ingestion; warm v15 entries lack the ranges and cannot be mapped.
57
- export const PARSER_VERSION = 16;
58
+ export const PARSER_VERSION = 17; // 17: Go method symbols receiver-qualified (Recv.M + member_of)
59
+ /** Tool package version, folded into the cache guard so UPGRADES auto-invalidate
60
+ * the cache — bumping PARSER_VERSION by hand is a discipline; this is a lock.
61
+ * (The stale-cache incident: upgraded binary served old per-file results.) */
62
+ export const TOOL_VERSION = (() => {
63
+ try {
64
+ // dist/local/analyze/ → ../../../package.json
65
+ const req = createRequire(import.meta.url);
66
+ return String(req("../../../package.json").version ?? "0");
67
+ }
68
+ catch {
69
+ return "0";
70
+ }
71
+ })();
58
72
  const SYMBOL_KINDS = new Set(["function", "class", "const", "method"]);
59
73
  /** Strict structural validation — persisted data is untrusted FOR SHAPE; reject anything off-shape. */
60
74
  function validSymbols(v) {
@@ -100,7 +114,7 @@ export class ParseCache {
100
114
  constructor(data) {
101
115
  this.entries = new Map();
102
116
  // A version mismatch (or no data) starts empty — never trust a stale schema.
103
- if (!data || data.version !== PARSER_VERSION || !data.entries || typeof data.entries !== "object")
117
+ if (!data || data.version !== PARSER_VERSION || data.tool !== TOOL_VERSION || !data.entries || typeof data.entries !== "object")
104
118
  return;
105
119
  for (const [key, raw] of Object.entries(data.entries)) {
106
120
  if (!raw || typeof raw !== "object")
@@ -159,6 +173,6 @@ export class ParseCache {
159
173
  if (e)
160
174
  entries[key] = e;
161
175
  }
162
- return { version: PARSER_VERSION, entries };
176
+ return { version: PARSER_VERSION, tool: TOOL_VERSION, entries };
163
177
  }
164
178
  }
@@ -252,8 +252,12 @@ export function extractTreeSitterSymbols(content, language) {
252
252
  if (kind && shouldEmitSymbol(node, language)) {
253
253
  const name = symbolName(node, cfg);
254
254
  if (name && !RESERVED_SYMBOL_NAMES.has(name)) {
255
+ // Go methods mint receiver-qualified names (`Recv.M`, mirroring TS/JS
256
+ // `Class.method` + member_of) so same-named methods on different receivers
257
+ // stay distinct symbols; an underivable receiver falls back to the bare name.
258
+ const recv = kind === "method" && language === "go" ? goReceiverBaseName(node) : undefined;
255
259
  add(kind === "method"
256
- ? { name, symbol_kind: kind, trivial_accessor: isTrivialAccessorBody(node, language), ...nodeLines(node) }
260
+ ? { name: recv ? `${recv}.${name}` : name, ...(recv ? { member_of: recv } : {}), symbol_kind: kind, trivial_accessor: isTrivialAccessorBody(node, language), ...nodeLines(node) }
257
261
  : { name, symbol_kind: kind, ...nodeLines(node) });
258
262
  }
259
263
  }
@@ -381,6 +385,20 @@ function functionName(node, language) {
381
385
  return name;
382
386
  return name;
383
387
  }
388
+ /**
389
+ * Base receiver type name for a Go method declaration — `(p *Parser)` → "Parser",
390
+ * `(g Generic[T])` → "Generic". Undefined when the receiver shape is underivable,
391
+ * in which case callers fall back to the bare method name (the pre-qualified
392
+ * behavior). NOT used for test-name extraction — suite test names must stay bare
393
+ * (`^Test` gate at extractGoProofCalls).
394
+ */
395
+ function goReceiverBaseName(node) {
396
+ const param = node.childForFieldName("receiver")?.namedChild(0);
397
+ let t = param?.childForFieldName("type") ?? undefined;
398
+ if (t && (t.type === "pointer_type" || t.type === "generic_type"))
399
+ t = t.namedChild(0) ?? undefined;
400
+ return t?.type === "type_identifier" ? t.text : undefined;
401
+ }
384
402
  function javaPackage(root) {
385
403
  const pkg = namedChildren(root).find((n) => n.type === "package_declaration");
386
404
  return pkg ? namedChildren(pkg).find((n) => n.type.endsWith("identifier"))?.text : undefined;
@@ -389,6 +407,29 @@ function goPackage(root) {
389
407
  const pkg = namedChildren(root).find((n) => n.type === "package_clause");
390
408
  return pkg ? namedChildren(pkg).find((n) => n.type === "package_identifier")?.text : undefined;
391
409
  }
410
+ /**
411
+ * Top-level Go function name → base name of its single declared result type
412
+ * (`func New(n int) *Parser` → "Parser"). ONLY a bare `type_identifier` (optionally
413
+ * behind one `*`) is captured; multi-return (`parameter_list`), interface, qualified
414
+ * (`pkg.T`), and generic results are omitted — constructor-based receiver pinning
415
+ * must fail closed on any result it cannot prove structurally.
416
+ */
417
+ function extractGoCtorResults(root) {
418
+ const out = {};
419
+ for (const child of namedChildren(root)) {
420
+ if (child.type !== "function_declaration")
421
+ continue;
422
+ const name = child.childForFieldName("name")?.text;
423
+ if (!name)
424
+ continue;
425
+ let r = child.childForFieldName("result") ?? undefined;
426
+ if (r?.type === "pointer_type")
427
+ r = r.namedChild(0) ?? undefined;
428
+ if (r?.type === "type_identifier")
429
+ out[name] = r.text;
430
+ }
431
+ return out;
432
+ }
392
433
  function kotlinPackage(root) {
393
434
  const pkg = namedChildren(root).find((n) => n.type === "package_header");
394
435
  return pkg ? namedChildren(pkg).find((n) => n.type === "identifier")?.text : undefined;
@@ -860,6 +901,11 @@ function goShortVarCalls(stmt) {
860
901
  function extractGoProofCalls(root, imports) {
861
902
  const out = [];
862
903
  const seen = new Set();
904
+ // Var name → constructor name for locals declared in the CURRENT test func by a
905
+ // bare same-package `x := New(...)` (single unqualified call). Reset per top-level
906
+ // test func; marks a qualified proof-call `p.M()` as receiver-local AND carries
907
+ // the ctor name so the analyzer can pin p's receiver type from New's result.
908
+ let receiverLocals = new Map();
863
909
  const assertLocals = goAssertLocals(imports);
864
910
  const dotAssertMethods = goDotAssertMethods(root);
865
911
  const suiteTypes = goCanonicalSuiteTypes(root, goSuiteLocals(imports));
@@ -869,7 +915,8 @@ function extractGoProofCalls(root, imports) {
869
915
  if (seen.has(key))
870
916
  continue;
871
917
  seen.add(key);
872
- out.push({ caller: testName, testName, ...c, shadowed: [...shadowed], assertion, ...(assertionLine ? { assertionLine } : {}) });
918
+ const receiverCtor = c.via === "qualified" && !!c.qualifier && !c.qualifier.includes(".") ? receiverLocals.get(c.qualifier) : undefined;
919
+ out.push({ caller: testName, testName, ...c, shadowed: [...shadowed], assertion, ...(assertionLine ? { assertionLine } : {}), ...(receiverCtor ? { receiverLocal: true, receiverCtor } : {}) });
873
920
  }
874
921
  };
875
922
  const processBlock = (block, testName, testingParams, shadowed) => {
@@ -934,7 +981,29 @@ function extractGoProofCalls(root, imports) {
934
981
  for (const name of sv.names)
935
982
  pending.set(name, sv.calls);
936
983
  }
984
+ if (stmt.type === "short_var_declaration") {
985
+ // A lone `x := New(...)` makes x a receiver-local: its type comes from a
986
+ // same-package constructor, so `x.M()` targets a same-package method. This
987
+ // check inspects the TOP-LEVEL RHS call directly (not goShortVarCalls, whose
988
+ // single-product-call rule drops `New(strings.NewReader(...))` for its nested
989
+ // arg — the real phcparser shape). Refuses composite literals, qualified/
990
+ // imported calls, and multi-assign — none are receiver-locals.
991
+ const rhsNames = new Set();
992
+ collectNames(stmt.childForFieldName("left") ?? stmt.namedChild(0), rhsNames);
993
+ const rhs = stmt.childForFieldName("right");
994
+ const rhsCalls = rhs ? namedChildren(rhs).filter((n) => n.type === "call_expression") : [];
995
+ if (rhsNames.size === 1 && rhsCalls.length === 1) {
996
+ const cp = callParts(rhsCalls[0], "go");
997
+ if (cp && cp.via === "free")
998
+ receiverLocals.set([...rhsNames][0], cp.callee);
999
+ }
1000
+ }
937
1001
  else if (stmt.type === "assignment_statement") {
1002
+ // Reassigning a receiver-local invalidates it too.
1003
+ const reassignedRl = new Set();
1004
+ collectNames(stmt.childForFieldName("left") ?? stmt.namedChild(0), reassignedRl);
1005
+ for (const name of reassignedRl)
1006
+ receiverLocals.delete(name);
938
1007
  // Plain reassignment kills the binding — the checked value is no longer F's.
939
1008
  const reassigned = new Set();
940
1009
  collectNames(stmt.childForFieldName("left") ?? stmt.namedChild(0), reassigned);
@@ -979,6 +1048,7 @@ function extractGoProofCalls(root, imports) {
979
1048
  const body = child.childForFieldName("body");
980
1049
  if (!testingParams.size || !body)
981
1050
  continue;
1051
+ receiverLocals = new Map();
982
1052
  processBlock(body, name, testingParams, localBindings(child, "go"));
983
1053
  }
984
1054
  return out;
@@ -1442,7 +1512,14 @@ export function extractTreeSitterStructure(content, language) {
1442
1512
  nextInsideFunction = true;
1443
1513
  }
1444
1514
  else {
1445
- const name = functionName(node, language);
1515
+ let name = functionName(node, language);
1516
+ // Go method symbols are receiver-qualified — attribute calls to the
1517
+ // qualified caller so Layer-1 edges keep matching the emitted symbol.
1518
+ if (name && language === "go" && node.type === "method_declaration") {
1519
+ const recv = goReceiverBaseName(node);
1520
+ if (recv)
1521
+ name = `${recv}.${name}`;
1522
+ }
1446
1523
  if (name) {
1447
1524
  nextCaller = name;
1448
1525
  nextShadowed = localBindings(node, language);
@@ -1461,7 +1538,7 @@ export function extractTreeSitterStructure(content, language) {
1461
1538
  const imports = extractImports(root, language);
1462
1539
  const result = {
1463
1540
  ...(language === "java" ? { packageName: javaPackage(root), javaClasses: javaClassInfos(root), javaProofCalls: extractJavaProofCalls(root, imports) } : {}),
1464
- ...(language === "go" ? { packageName: goPackage(root) } : {}),
1541
+ ...(language === "go" ? { packageName: goPackage(root), goCtorResults: extractGoCtorResults(root) } : {}),
1465
1542
  ...(language === "kotlin" ? { packageName: kotlinPackage(root), topLevelSymbols: kotlinTopLevelSymbols(root) } : {}),
1466
1543
  ...(language === "php" ? { moduleName: phpNamespace(root) } : {}),
1467
1544
  ...(language === "csharp" ? { moduleName: csharpNamespace(root) } : {}),
@@ -108,15 +108,18 @@ function pytestNodeidsForTarget(sourceRoot, testRel, testName) {
108
108
  * `behavior_surface === "entrypoint_adjacent"`, and carrying NO `denominator_reason_code`
109
109
  * (infra_behavior_surface / not_entry_point_adjacent).
110
110
  *
111
- * Language: TS/JS (unchanged), Go (G-INT-2), OR Java (J-INT-2). Go dynamic proof
112
- * (G-1) proves FREE FUNCTIONS ONLY a Go method classifies `unrunnable` and never
113
- * mints, so admitting one is safe (never a false Proven) but wastes an attempt; when
114
- * the clean `symbol_kind === "method"` signal is present we exclude it up front. Java
115
- * dynamic proof (J-1) is the INVERSE it proves single-top-level-return METHODS, so
116
- * we admit Java methods; a non-J-1-shape method (void/constructor/nested/generic) just
117
- * classifies `unrunnable` and never mints, safe by construction. Everything downstream
118
- * of `closed`/the cert is language-agnostic and mints Go/Java only through the
119
- * unchanged G-INT-1/J-INT-1 gates.
111
+ * Language: TS/JS (unchanged), Go (G-INT-2), OR Java (J-INT-2). This STRICT predicate
112
+ * keeps Go to FREE FUNCTIONS ONLY. The Go oracle can also prove receiver METHODS, but
113
+ * a method's no-false-Proven story rests entirely on the analyzer-minted hard
114
+ * receiver-local TESTED_BY/COVERS edge (package-unique method-name gating) so Go
115
+ * methods are admitted solely by `isEligibleHardExistingTarget` on the hard-edge lane,
116
+ * never here: this predicate also feeds PR/changed-scope selection, weak MAY_*
117
+ * expansion, and generation candidate filtering, where no hard edge backs the pick.
118
+ * Java dynamic proof (J-1) is the INVERSE it proves single-top-level-return METHODS,
119
+ * so we admit Java methods; a non-J-1-shape method (void/constructor/nested/generic)
120
+ * just classifies `unrunnable` and never mints, safe by construction. Everything
121
+ * downstream of `closed`/the cert is language-agnostic and mints Go/Java only through
122
+ * the unchanged G-INT-1/J-INT-1 gates.
120
123
  */
121
124
  export function isEligibleProvableTarget(node) {
122
125
  if (!node || node.kind !== "CodeSymbol")
@@ -133,18 +136,27 @@ export function isEligibleProvableTarget(node) {
133
136
  * The LANGUAGE + oracle-SHAPE half of eligibility, factored out so both the strict
134
137
  * `isEligibleProvableTarget` (which layers the entry-point-adjacent SCOPE guards on top)
135
138
  * and the relaxed hard-edge existing-tests path share ONE definition of "a shape the
136
- * oracle can prove". CodeSymbol + a TS/JS file, a Go free function, a Java method, or a
137
- * Python function/method.
139
+ * oracle can prove". CodeSymbol + a TS/JS file, a Go free function (a receiver method
140
+ * only when the caller opts in via `allowGoMethod`), a Java method, or a Python
141
+ * function/method.
138
142
  */
139
- function matchesProvableLanguageShape(node) {
143
+ function matchesProvableLanguageShape(node, opts) {
140
144
  if (node.kind !== "CodeSymbol")
141
145
  return false;
142
146
  const file = codeSymbolFile(node);
143
147
  if (isTsJsFile(file))
144
148
  return true;
145
- // Go: free functions only. A method is out of scope for the Go oracle (G-1 refuses it).
146
- if (isGoFile(file))
147
- return node.properties.symbol_kind !== "method";
149
+ // Go: free functions only, unless the caller opts into methods (`allowGoMethod`).
150
+ // Only the hard-edge lane opts in: a method's no-false-Proven story rests on the
151
+ // analyzer's uniqueness-gated receiver-local TESTED_BY/COVERS edge, which the strict
152
+ // callers (changed-scope selection, weak MAY_* expansion, generation candidates)
153
+ // do not have — admitting methods there would break the invariant that a Go method
154
+ // only ever becomes a proof attempt via a hard receiver-local edge.
155
+ if (isGoFile(file)) {
156
+ if (node.properties.symbol_kind === "function")
157
+ return true;
158
+ return opts?.allowGoMethod === true && node.properties.symbol_kind === "method";
159
+ }
148
160
  // Java: METHODS only (J-1 proves single-top-level-return methods). A non-J-1-shape
149
161
  // method classifies `unrunnable` and never mints, so admitting all Java methods is
150
162
  // safe (never a false Proven); a non-method Java symbol (a class container) is out
@@ -173,7 +185,11 @@ function matchesProvableLanguageShape(node) {
173
185
  * by the frozen oracle, never false-Proven. Guards deliberately KEPT:
174
186
  * - `infra_behavior_surface` still excludes plumbing (getters/registry accessors) — a
175
187
  * hard edge does not buy an infra symbol an attempt (waste, and preserves the #4 bar);
176
- * - the language + oracle-shape filter (never hand a class container / Go method down).
188
+ * - the language + oracle-shape filter (never hand a class container down).
189
+ * This is also the ONLY caller that admits Go receiver METHODS (`allowGoMethod`): it is
190
+ * reached solely behind a hard TESTED_BY/COVERS edge, and the analyzer mints a method
191
+ * edge only for the uniqueness-gated receiver-local shape — the strict predicate keeps
192
+ * Go methods out of every other selection path.
177
193
  * Used solely on the hard lane; weak MAY_* fan-out stays strict.
178
194
  */
179
195
  function isEligibleHardExistingTarget(node) {
@@ -181,7 +197,7 @@ function isEligibleHardExistingTarget(node) {
181
197
  return false;
182
198
  if (node.properties.denominator_reason_code === "infra_behavior_surface")
183
199
  return false;
184
- return matchesProvableLanguageShape(node);
200
+ return matchesProvableLanguageShape(node, { allowGoMethod: true });
185
201
  }
186
202
  /** Go `_test.go` top-level test-name regex (matches `extractTestNames`'s Go pattern). */
187
203
  const GO_TEST_NAME_RE = /^Test[A-Za-z0-9_]+$/;
@@ -206,9 +222,10 @@ function anchorGoTestRun(name) {
206
222
  * its target test BY NAME (`go test -run ^TestX$`), so auto-drive must derive that
207
223
  * name — G-INT-1 took it as an explicit input.
208
224
  *
209
- * The link is the analyzer's HARD `TESTED_BY`/`COVERS` proof edge (Go free-fn sym ↔
210
- * `test:<file>_test.go`), emitted only for an eligible Go symbol whose test genuinely
211
- * asserts on it. PRIMARY: the edge carries `properties.test_name` — the EXACT enclosing
225
+ * The link is the analyzer's HARD `TESTED_BY`/`COVERS` proof edge (Go sym — free fn,
226
+ * or receiver-local method — ↔ `test:<file>_test.go`), emitted only for an eligible Go
227
+ * symbol whose test genuinely asserts on it. PRIMARY: the edge carries
228
+ * `properties.test_name` — the EXACT enclosing
212
229
  * `func TestXxx` where the assertion witnessed THIS target (structural metadata, never
213
230
  * proof) — which disambiguates even a `_test.go` file with many tests. FALLBACK (old
214
231
  * graphs / no edge metadata): the linked TestCase node's file-level `test_names[]`,
@@ -519,7 +536,8 @@ export function existingAssociatedTests(graph, nodeById) {
519
536
  };
520
537
  // `hard` = TESTED_BY/COVERS (the confirmer's structural links); weak = MAY_* candidate
521
538
  // edges. Hard is recorded before weak (graph.edges scanned first), so a test already
522
- // linked hard is never downgraded; a later weak dup only upgrades an existing weak to hard.
539
+ // linked hard is never downgraded, and a later weak dup of the same pair is a no-op —
540
+ // the hard flag can only ever originate from a genuine TESTED_BY/COVERS edge.
523
541
  const add = (symId, testRel, hard, testName) => {
524
542
  // A HARD TESTED_BY/COVERS edge is a real derivable test; admit it even when the symbol
525
543
  // is not_entry_point_adjacent (relaxed shape-only guard). Weak MAY_* fan-out stays strict.