@orangepro/orangepro-mcp 0.1.0

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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. package/scripts/spikes/failure-summary.mjs +29 -0
@@ -0,0 +1,620 @@
1
+ /**
2
+ * Auto-prove orchestration for `opro start` (PR 1 of the auto-drive-to-proven spec).
3
+ *
4
+ * ORCHESTRATION ONLY — this file mints no proof. After analyze, when a provider key
5
+ * exists and the user did not opt out, it: selects the top provable TS/JS CodeSymbol
6
+ * targets (ORS-ranked), generates a runnable test per target via the EXISTING
7
+ * `generateTests`, writes it under `orangepro_generated/`, and runs it through the
8
+ * UNCHANGED `opProveLoop`/dynamic-proof oracle. Proven is minted only by that oracle;
9
+ * a static / non-killing / generation-failed / setup-failed test is an honest skip and
10
+ * NEVER becomes Proven. A later failed attempt cannot clobber a prior Proven (the #162
11
+ * fingerprint-scoped RTM selection guarantees this — this file does not defeat it).
12
+ *
13
+ * No key ⇒ writes NO files, mints NO proof, returns explicit guidance.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { basename, dirname, posix, resolve, sep } from "node:path";
17
+ import { generateTests, readDeclaredDeps, unresolvedLocalImports } from "./generate/generator.js";
18
+ import { GENERATED_DIR, runHintsFor } from "./generate/runHints.js";
19
+ import { rankRiskGaps } from "./score/risk.js";
20
+ import { resolveProviderConfig } from "./localConfig.js";
21
+ import { buildProvider } from "./generate/providers.js";
22
+ import { resolveContained } from "./reprove/paths.js";
23
+ import { buildRtm } from "./rtm.js";
24
+ import { loadLedger } from "./ledger.js";
25
+ import { loadGraph, workspacePaths } from "./workspace.js";
26
+ import { systemClock } from "./util/time.js";
27
+ import { redactSecrets } from "./util/redact.js";
28
+ import { classifyBaselineFailure, EXPERIMENTAL_SQLITE_TEST_ENV, IMPORT_TIME_CATEGORIES, isNeedsSetupCategory, readEnginesNode, targetNeedsExperimentalSqlite } from "./proofRunnability.js";
29
+ /** Real dynamic proof is TS/JS-only; only these targets are attemptable. */
30
+ function isTsJsFile(file) {
31
+ return /\.[cm]?[jt]sx?$/i.test(file);
32
+ }
33
+ /**
34
+ * SOLE trust barrier for auto-prove target selection. `opDynamicProof` has NO
35
+ * eligibility guard — `resolveTargetSymbol` resolves ANY CodeSymbol and the prove
36
+ * path never reads eligibility — so handing it an excluded infra symbol would mint
37
+ * Proven against plumbing. Auto-prove must therefore refuse anything that is not an
38
+ * entry-point-adjacent behavior surface: eligible (top-level `denominator_eligible`),
39
+ * `behavior_surface === "entrypoint_adjacent"`, and carrying NO `denominator_reason_code`
40
+ * (infra_behavior_surface / not_entry_point_adjacent). TS/JS file, always.
41
+ */
42
+ export function isEligibleProvableTarget(node) {
43
+ if (!node || node.kind !== "CodeSymbol")
44
+ return false;
45
+ if (node.denominator_eligible !== true)
46
+ return false;
47
+ if (node.properties.behavior_surface !== "entrypoint_adjacent")
48
+ return false;
49
+ if (node.properties.denominator_reason_code != null)
50
+ return false;
51
+ const file = typeof node.properties.file === "string" ? node.properties.file : node.external_id.replace(/^sym:/, "").split("#")[0];
52
+ return isTsJsFile(file);
53
+ }
54
+ const GENERATED_HEADER = "// Generated by OrangePro — do not edit";
55
+ // "Static map first, dynamically prove top 5": ONE unified dynamic-proof budget spans
56
+ // BOTH lanes (existing-tests first, then generation for the remaining eligible gaps).
57
+ // TOTAL attempts (existing + generation) are capped at this budget; `--auto-limit N`
58
+ // overrides it (clamped to MAX_AUTO_LIMIT) for deeper runs (`opro start --auto-limit 25`,
59
+ // `opro prove`, `opro prove-loop`). Static breadth (behaviors/flows/associated/risk) is
60
+ // never gated on this budget — only the dynamic verification pass is.
61
+ const DEFAULT_AUTO_LIMIT = 5;
62
+ const MAX_AUTO_LIMIT = 50;
63
+ /** Generator caps a single call at 5 (MAX_LIMIT); page candidates in windows of that. */
64
+ const GEN_WINDOW = 5;
65
+ // ponytail: cap the weak MAY_*/MAY_RELATE_TO fan-out a single symbol contributes so one hot
66
+ // file (every eligible symbol × every importing test) cannot starve the shared budget before
67
+ // a provable hard-edge symbol is tried. Small K; promote to a flag if a repo needs a wider sweep.
68
+ const EXISTING_LANE_MAX_WEAK_PER_SYMBOL = 3;
69
+ export const NO_KEY_MESSAGE = "No provider key; auto-prove skipped — add OPENAI_API_KEY / ANTHROPIC_API_KEY, or use the OrangePro MCP in your coding agent.";
70
+ /**
71
+ * Resolve the contained absolute path for a generated file, REJECTING any name that
72
+ * escapes `<sourceRoot>/orangepro_generated/`. Exported so the guardrail is unit-tested.
73
+ */
74
+ export function containedGeneratedPath(sourceRoot, filename) {
75
+ const dir = resolve(sourceRoot, GENERATED_DIR);
76
+ return resolveContained(dir, filename);
77
+ }
78
+ /** The source file a `sym:` target lives in (workspace-relative). */
79
+ function symbolFile(target) {
80
+ return target.slice("sym:".length).split("#")[0] ?? "";
81
+ }
82
+ /** Relative import specifiers (`./x`, `../x`) in import/export/require/dynamic-import positions. */
83
+ const REL_IMPORT_RE = /(\bfrom\s*|\bimport\s*|\brequire\s*\(\s*|\bimport\s*\(\s*)(['"])(\.\.?\/[^'"]*)\2/g;
84
+ /**
85
+ * Re-point a generated test's RELATIVE imports so they resolve from
86
+ * `orangepro_generated/`. The generator grounds imports against the SOURCE dir (the
87
+ * model co-locates the test with the module under test, e.g. `./order.service`), but
88
+ * autoProve writes the test to `orangepro_generated/`. Resolve each `./x` / `../x`
89
+ * against the target source file's directory, then re-express it relative to
90
+ * `orangepro_generated/` (POSIX, leading `./`) — e.g. `./order.service` →
91
+ * `../src/order.service`. Only import/require positions are touched, never plain
92
+ * string literals. TS/JS only (dynamic proof is TS/JS-only).
93
+ */
94
+ function rewriteRelativeImports(body, targetFileRel, generatedDir) {
95
+ const baseDir = posix.dirname(targetFileRel.split(sep).join("/"));
96
+ return body.replace(REL_IMPORT_RE, (_m, prefix, quote, spec) => {
97
+ const abs = posix.normalize(posix.join(baseDir === "." ? "" : baseDir, spec));
98
+ let rel = posix.relative(generatedDir, abs);
99
+ if (!rel.startsWith("."))
100
+ rel = `./${rel}`;
101
+ return `${prefix}${quote}${rel}${quote}`;
102
+ });
103
+ }
104
+ function fileReaderFor(root) {
105
+ const absRoot = resolve(root);
106
+ return (relPath) => {
107
+ const abs = resolve(absRoot, relPath);
108
+ if (abs !== absRoot && !abs.startsWith(absRoot + sep))
109
+ return null;
110
+ try {
111
+ return readFileSync(abs, "utf8");
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ };
117
+ }
118
+ /**
119
+ * R-1: classify a prove-loop result. A RED baseline is classified from the oracle's
120
+ * already-redacted `baseline.failureSummary` (+ the target's declared engines.node vs the
121
+ * runner Node) into a SPECIFIC reason. logic_failure is a genuine failing test on the real
122
+ * code → an honest non-proof, NEVER needs_setup. No proof is minted here.
123
+ */
124
+ function classifyProof(result, ctx) {
125
+ if ("status" in result && result.status === "unrunnable") {
126
+ // Setup did not run (env non-event) — nothing was minted, target needs setup.
127
+ return { classification: "needs_setup", reason: result.reason };
128
+ }
129
+ const dyn = result;
130
+ const record = dyn.record;
131
+ if (record.closed)
132
+ return { classification: "proven" };
133
+ const cert = record.dynamic_proof;
134
+ if (cert && cert.baseline_green === false) {
135
+ const { category, reason } = classifyBaselineFailure({
136
+ failureSummary: dyn.oracle.baseline?.failureSummary,
137
+ enginesNode: readEnginesNode(ctx.sourceRoot, ctx.targetFileRel),
138
+ runnerNode: ctx.runnerNode ?? process.version
139
+ });
140
+ const classification = isNeedsSetupCategory(category) ? "needs_setup" : "non_killing";
141
+ return { classification, reason: redactSecrets(reason), category };
142
+ }
143
+ return { classification: "non_killing", reason: record.reason ?? "Mutant survived; the test does not assert on the target's real behavior." };
144
+ }
145
+ /**
146
+ * R-1 sibling-dedup key: a baseline-red import-time failure is a deterministic property of
147
+ * loading the TARGET FILE with a given runner, independent of which test runs it — so
148
+ * same-file siblings share it. Keyed on the TARGET FILE only (every caller pins runner to
149
+ * undefined): the sole deduped cause is engine_mismatch, a package-level fact both lanes
150
+ * classify against the SAME process.version, so the runner must not be in the key. The old
151
+ * {runner, target file} key split the cache across lanes (lane 1 wrote "auto <file>", the
152
+ * generation lane read "<runner> <file>" -> never matched), silently disabling cross-lane
153
+ * dedup. NEVER merges across different files or a different failure class.
154
+ */
155
+ function dedupKey(runner, targetFileRel) {
156
+ return `${runner ?? "auto"}\u0000${targetFileRel}`;
157
+ }
158
+ /** A same-file sibling deduped WITHOUT re-running: shares the first attempt's redacted reason. */
159
+ function dedupedAttempt(targetSymbol, testPath, targetFileRel, blocked) {
160
+ return {
161
+ target_symbol: targetSymbol,
162
+ test_path: testPath,
163
+ classification: "needs_setup",
164
+ reason: `${blocked.reason} (shared root cause with a sibling in ${targetFileRel}; not re-run).`,
165
+ category: blocked.category,
166
+ deduped: true
167
+ };
168
+ }
169
+ /**
170
+ * R-2: the exact experimental-builtin env profile for a target that references node:sqlite,
171
+ * else undefined. Auto-applied only on a confident source reference; forwarded through the
172
+ * EXISTING test_env path (opProveLoop → oracle --test-env, spike-allowlisted). It only makes
173
+ * the baseline RUNNABLE — never asserts, mocks, or mints Proven. Ambient NODE_OPTIONS is never
174
+ * forwarded (this is an explicit fixed flag, not process.env).
175
+ */
176
+ function experimentalSqliteTestEnv(reader, targetFileRel) {
177
+ if (!targetNeedsExperimentalSqlite(reader, targetFileRel))
178
+ return undefined;
179
+ // Only inject where the runner Node actually accepts the flag in NODE_OPTIONS. On Node < 22.5 the
180
+ // flag is rejected outright ("--experimental-sqlite is not allowed in NODE_OPTIONS", exit 9), which
181
+ // would make the baseline unrunnable for a spurious reason; there node:sqlite is unavailable anyway,
182
+ // so we skip injection and let R-1 classify it honestly as an experimental-builtin needs_setup.
183
+ // The oracle spawns the runner with this same Node (process.execPath), so this check is authoritative.
184
+ if (!process.allowedNodeEnvironmentFlags.has("--experimental-sqlite"))
185
+ return undefined;
186
+ return [EXPERIMENTAL_SQLITE_TEST_ENV];
187
+ }
188
+ const zeroSummary = (status, ran, reason) => ({
189
+ ran,
190
+ status,
191
+ reason,
192
+ attempted: 0,
193
+ proven: 0,
194
+ needs_setup: [],
195
+ skipped: [],
196
+ generated_files: [],
197
+ attempts: []
198
+ });
199
+ function symbolFileOf(node) {
200
+ return typeof node.properties.file === "string" ? node.properties.file : node.external_id.replace(/^sym:/, "").split("#")[0];
201
+ }
202
+ /**
203
+ * Map each ELIGIBLE CodeSymbol to the existing test files statically linked to it — the
204
+ * repo's OWN tests. Sources are exactly the edges RTM/coverage read for the "Associated"
205
+ * tier:
206
+ * - hard TESTED_BY/COVERS edges (the confirmer's sym↔TestCase links, `graph.edges`);
207
+ * - weak MAY_BE_TESTED_BY/MAY_COVER candidate edges (sym↔TestCase, `graph.candidate_edges`);
208
+ * - weak MAY_RELATE_TO candidate edges (a test file resolved-imports a source FILE) —
209
+ * the broadest signal, mapped to every eligible symbol in that source file.
210
+ * The static edge only SELECTS a target+test pair for the oracle — it is never itself proof,
211
+ * and a symbol the test does not actually exercise simply survives the mutant (honest skip).
212
+ * AI-suggested candidate edges are excluded (a model guess is not an existing test).
213
+ */
214
+ export function existingAssociatedTests(graph, nodeById) {
215
+ const out = new Map();
216
+ const testFileOf = (id) => {
217
+ const n = nodeById.get(id);
218
+ if (!n || n.kind !== "TestCase")
219
+ return null;
220
+ return typeof n.properties.file === "string" ? n.properties.file : id.replace(/^test:/, "");
221
+ };
222
+ // `hard` = TESTED_BY/COVERS (the confirmer's structural links); weak = MAY_* candidate
223
+ // edges. Hard is recorded before weak (graph.edges scanned first), so a test already
224
+ // linked hard is never downgraded; a later weak dup only upgrades an existing weak to hard.
225
+ const add = (symId, testRel, hard) => {
226
+ if (!isEligibleProvableTarget(nodeById.get(symId)))
227
+ return;
228
+ const list = out.get(symId);
229
+ if (!list) {
230
+ out.set(symId, [{ test: testRel, hard }]);
231
+ return;
232
+ }
233
+ const existing = list.find((t) => t.test === testRel);
234
+ if (existing) {
235
+ if (hard)
236
+ existing.hard = true;
237
+ return;
238
+ }
239
+ list.push({ test: testRel, hard });
240
+ };
241
+ // A sym↔test edge joins one TestCase endpoint to one CodeSymbol endpoint; resolve
242
+ // whichever side is the symbol so both edge directions are handled uniformly.
243
+ const link = (a, b, hard) => {
244
+ const tb = testFileOf(b);
245
+ if (tb && nodeById.get(a)?.kind === "CodeSymbol")
246
+ return add(a, tb, hard);
247
+ const ta = testFileOf(a);
248
+ if (ta && nodeById.get(b)?.kind === "CodeSymbol")
249
+ add(b, ta, hard);
250
+ };
251
+ // Eligible symbols grouped by source file, for the file-level MAY_RELATE_TO expansion.
252
+ const eligibleByFile = new Map();
253
+ for (const n of graph.nodes) {
254
+ if (!isEligibleProvableTarget(n))
255
+ continue;
256
+ const f = symbolFileOf(n);
257
+ const list = eligibleByFile.get(f);
258
+ if (list)
259
+ list.push(n.external_id);
260
+ else
261
+ eligibleByFile.set(f, [n.external_id]);
262
+ }
263
+ // MAY_RELATE_TO endpoints are plain workspace-relative file paths; a test file's node id
264
+ // is `test:${relPath}`. Return the test relPath for whichever endpoint is a TestCase file.
265
+ const testRelForFile = (relPath) => {
266
+ const n = nodeById.get(`test:${relPath}`);
267
+ return n && n.kind === "TestCase" ? relPath : null;
268
+ };
269
+ for (const e of graph.edges) {
270
+ if (e.relationship_type === "TESTED_BY" || e.relationship_type === "COVERS")
271
+ link(e.from_external_id, e.to_external_id, true);
272
+ }
273
+ for (const e of graph.candidate_edges ?? []) {
274
+ if (e.review_status === "ai_suggested")
275
+ continue;
276
+ if (e.relationship_type === "MAY_BE_TESTED_BY" || e.relationship_type === "MAY_COVER") {
277
+ link(e.from_external_id, e.to_external_id, false);
278
+ }
279
+ else if (e.relationship_type === "MAY_RELATE_TO") {
280
+ const fromTest = testRelForFile(e.from_external_id);
281
+ const testRel = fromTest ?? testRelForFile(e.to_external_id);
282
+ const sourceRel = fromTest ? e.to_external_id : e.from_external_id;
283
+ if (testRel)
284
+ for (const symId of eligibleByFile.get(sourceRel) ?? [])
285
+ add(symId, testRel, false);
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ /**
291
+ * Fix 3 — deterministic hard-first, weak-capped attempt order. Every hard TESTED_BY/COVERS
292
+ * pair precedes ANY weak MAY_* pair (a provable hard-edge symbol is tried before weak fan-out
293
+ * burns the shared budget), and each symbol contributes at most `maxWeakPerSymbol` weak pairs
294
+ * so one hot MAY_RELATE_TO file (its every eligible symbol × every importing test) can't starve
295
+ * the budget. Order within each tier follows the Map's insertion order (graph node/edge order),
296
+ * so the result is deterministic.
297
+ */
298
+ export function orderExistingAttempts(testsBySymbol, maxWeakPerSymbol = EXISTING_LANE_MAX_WEAK_PER_SYMBOL) {
299
+ const hard = [];
300
+ const weak = [];
301
+ for (const [symId, tests] of testsBySymbol) {
302
+ let weakCount = 0;
303
+ for (const t of tests) {
304
+ if (t.hard)
305
+ hard.push({ symId, testRel: t.test, hard: true });
306
+ else if (weakCount++ < maxWeakPerSymbol)
307
+ weak.push({ symId, testRel: t.test, hard: false });
308
+ }
309
+ }
310
+ return [...hard, ...weak];
311
+ }
312
+ /**
313
+ * PR 1.5 lane — prove the repo's OWN existing tests, NO provider key. For each eligible
314
+ * target with an existing associated test, run the UNCHANGED `opProveLoop` with the test
315
+ * IN ITS ORIGINAL LOCATION (never copied/relocated — that avoids the generation lane's
316
+ * import-grounding pitfall) and a null-sentinel mutant. Closes → Proven; survives /
317
+ * crashes-pre-assert / setup-fails → honest skip (never Proven, and #162 best-ever
318
+ * selection means it can never clobber a prior Proven). Consumes from the SHARED unified
319
+ * budget (`budget`) — the generation lane gets whatever this lane leaves unspent, so
320
+ * TOTAL attempts (existing + generation) never exceed the budget.
321
+ */
322
+ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, importTimeBlocked, budget) {
323
+ const attempts = [];
324
+ const needsSetup = [];
325
+ const provenSymbols = new Set();
326
+ let proven = 0;
327
+ let attempted = 0;
328
+ const changed = opts.changedFiles && opts.changedFiles.length > 0 ? new Set(opts.changedFiles) : null;
329
+ const reader = fileReaderFor(sourceRoot); // R-2: source scan for the node:sqlite env profile
330
+ // Fix 3: hard TESTED_BY/COVERS pairs first, weak MAY_* pairs after and capped per symbol.
331
+ const queue = orderExistingAttempts(existingAssociatedTests(graph, nodeById));
332
+ for (const { symId, testRel } of queue) {
333
+ if (attempted >= budget)
334
+ break;
335
+ const node = nodeById.get(symId);
336
+ // Redundant with existingAssociatedTests' own filter, but the eligibility barrier is
337
+ // the sole guard against handing plumbing to the guard-less prove path — assert it here too.
338
+ if (!isEligibleProvableTarget(node))
339
+ continue;
340
+ if (changed && !changed.has(symbolFileOf(node)))
341
+ continue;
342
+ // Fix 1: already Proven for the CURRENT code (fingerprint match under #162) → skip. Re-closing
343
+ // it would overstate newly-proven and append a redundant closed cert every run.
344
+ if (alreadyProven.has(symId))
345
+ continue;
346
+ // Proven earlier THIS run — one closing existing test per symbol is enough.
347
+ if (provenSymbols.has(symId))
348
+ continue;
349
+ const targetFileRel = symbolFileOf(node);
350
+ // R-1 sibling dedup: a prior same-file attempt hit a package-level env root cause
351
+ // (engine_mismatch: runner Node outside the declared engines range). Every sibling in this
352
+ // file fails baseline identically → mark it
353
+ // needs_setup WITHOUT re-running (and WITHOUT consuming the attempt budget).
354
+ const blocked = importTimeBlocked.get(dedupKey(undefined, targetFileRel));
355
+ if (blocked) {
356
+ const attempt = dedupedAttempt(symId, testRel, targetFileRel, blocked);
357
+ attempts.push(attempt);
358
+ needsSetup.push(attempt);
359
+ continue;
360
+ }
361
+ attempted++;
362
+ // R-2: inject NODE_OPTIONS=--experimental-sqlite via the existing test_env path when the
363
+ // target references node:sqlite. Makes the baseline runnable only; never mints Proven.
364
+ const testEnv = experimentalSqliteTestEnv(reader, targetFileRel);
365
+ let result;
366
+ try {
367
+ result = proveLoop(root,
368
+ // link_node_modules: the isolated proof copy excludes node_modules; without linking,
369
+ // any target/test importing a repo dependency fails baseline → needs_setup. Linking only
370
+ // makes real tests runnable — Proven still requires the dynamic oracle's sentinel kill.
371
+ { target_symbol: symId, source: sourceRoot, test_path: testRel, replacement: "return null;", link_node_modules: true, ...(testEnv ? { test_env: testEnv } : {}), run_id: `auto-prove-existing-${attempted}` }, proveDeps);
372
+ }
373
+ catch (e) {
374
+ const attempt = {
375
+ target_symbol: symId,
376
+ test_path: testRel,
377
+ classification: "needs_setup",
378
+ reason: `Proof could not run: ${redactSecrets(errMsg(e))}`
379
+ };
380
+ attempts.push(attempt);
381
+ needsSetup.push(attempt);
382
+ continue;
383
+ }
384
+ const { classification, reason, category } = classifyProof(result, { sourceRoot, targetFileRel });
385
+ const attempt = { target_symbol: symId, test_path: testRel, classification, reason, category };
386
+ attempts.push(attempt);
387
+ if (classification === "proven") {
388
+ proven++;
389
+ provenSymbols.add(symId); // its remaining queued tests are skipped above
390
+ continue;
391
+ }
392
+ if (classification === "needs_setup") {
393
+ needsSetup.push(attempt);
394
+ // Cache an import-time root cause so same-file siblings dedup instead of re-running.
395
+ if (category && IMPORT_TIME_CATEGORIES.has(category)) {
396
+ importTimeBlocked.set(dedupKey(undefined, targetFileRel), { category, reason: reason ?? "" });
397
+ }
398
+ }
399
+ // non_killing → keep trying this symbol's other associated tests, if any.
400
+ }
401
+ return { attempts, needsSetup, proven, attempted, provenSymbols };
402
+ }
403
+ /**
404
+ * Drive prove for the top provable TS/JS targets. Two lanes: (1) prove the repo's OWN
405
+ * existing associated tests — NO key, runs first (PR 1.5); (2) key-gated generate → prove
406
+ * to fill the remaining gaps (PR 1). The existing oracle is the sole proof judge for both;
407
+ * generation writes are contained under orangepro_generated/.
408
+ */
409
+ export async function autoProve(root, opts, deps) {
410
+ if (opts.noAuto)
411
+ return zeroSummary("disabled", false, "--no-auto: auto-prove disabled; analyze-only.");
412
+ const clock = deps.clock ?? systemClock;
413
+ const proveLoop = deps.proveLoop;
414
+ const proveDeps = {
415
+ clock,
416
+ env: deps.env,
417
+ dynamicProofRunner: deps.dynamicProofRunner,
418
+ coverageRunner: deps.coverageRunner,
419
+ analyze: deps.analyze,
420
+ aiProvider: deps.aiProvider
421
+ };
422
+ const graph = loadGraph(workspacePaths(root).graphPath);
423
+ const sourceRoot = resolve(graph.workspace.root);
424
+ const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
425
+ // Fix 1: symbols already Proven for the CURRENT code. `buildRtm(graph, ledger)` IS the
426
+ // #162 fingerprint-scoped decision that sets a row's tier to "proven" (via
427
+ // selectLedgerBySymbol), so we reuse it verbatim rather than re-deriving fingerprints.
428
+ // Both lanes skip these so res.proven counts only NEWLY-minted proofs this run and no
429
+ // redundant closed cert is appended. A changed file → different fingerprint → not in
430
+ // this set → still attempted (re-prove for the new code).
431
+ const alreadyProven = new Set(buildRtm(graph, loadLedger(root)).rows
432
+ .filter((r) => r.evidence_tier === "proven")
433
+ .map((r) => r.code_symbol)
434
+ .filter(Boolean));
435
+ // R-1: shared sibling-dedup cache of import-time baseline failures. Spans BOTH lanes so a
436
+ // node:sqlite-style root cause found once is never re-run across same-file siblings.
437
+ const importTimeBlocked = new Map();
438
+ // ONE unified dynamic-proof budget for the whole pass (existing-first → then generation).
439
+ // Default 5 ("dynamically prove top 5"); `--auto-limit N` overrides it, clamped to
440
+ // MAX_AUTO_LIMIT. The existing lane consumes from this budget and the generation lane gets
441
+ // only the remainder, so TOTAL attempts (existing + generation) are ≤ budget.
442
+ const autoLimit = Math.max(1, Math.min(MAX_AUTO_LIMIT, Math.floor(opts.autoLimit ?? DEFAULT_AUTO_LIMIT)));
443
+ // ── Lane 1: existing associated tests — NO key required, runs FIRST (PR 1.5). ──
444
+ const ex = proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, importTimeBlocked, autoLimit);
445
+ // Key gate applies ONLY to the generation lane. No provider key ⇒ generation is skipped
446
+ // (no files, no fake proof) with explicit guidance; the existing-tests lane still counts.
447
+ const providerConfig = resolveProviderConfig(deps.env, { provider: opts.provider, model: opts.model });
448
+ if (!providerConfig) {
449
+ const status = ex.proven > 0 ? "proven-run" : ex.attempted > 0 ? "ran-no-proof" : "skipped-no-key";
450
+ return {
451
+ ran: ex.attempted > 0,
452
+ status,
453
+ reason: NO_KEY_MESSAGE,
454
+ attempted: ex.attempted,
455
+ proven: ex.proven,
456
+ needs_setup: ex.needsSetup,
457
+ skipped: [],
458
+ generated_files: [],
459
+ attempts: ex.attempts
460
+ };
461
+ }
462
+ const provider = buildProvider(providerConfig);
463
+ const generate = deps.generate ?? generateTests;
464
+ const reader = fileReaderFor(sourceRoot);
465
+ // Generation gets only the budget the existing-tests lane left unspent, so existing +
466
+ // generation attempts total ≤ autoLimit. Exhausted budget ⇒ genBudget 0 ⇒ no provider call.
467
+ const genBudget = Math.max(0, autoLimit - ex.attempted);
468
+ // Candidates = ORS-ranked provable CodeSymbols. rankRiskGaps ranks by OrangePro Risk
469
+ // Score and excludes hard-confirmed symbols; we ALSO enforce the eligibility barrier
470
+ // explicitly (isEligibleProvableTarget) at selection so an excluded infra symbol can
471
+ // NEVER reach the guard-less prove path, and drop any symbol the existing-tests lane
472
+ // already proved so generation fills only the remaining gaps. PR mode scopes to the
473
+ // eligible symbols whose file is in the changed set (changed_files → symbols, NOT
474
+ // affected_behaviors — those are Requirement/UserFlow/BusinessRule ids opDynamicProof
475
+ // cannot prove).
476
+ let candidates = rankRiskGaps(graph, { repoRoot: sourceRoot, limit: 500 }).filter((g) => isEligibleProvableTarget(nodeById.get(g.id)) && !ex.provenSymbols.has(g.id) && !alreadyProven.has(g.id));
477
+ if (opts.changedFiles && opts.changedFiles.length > 0) {
478
+ const changed = new Set(opts.changedFiles);
479
+ candidates = candidates.filter((g) => changed.has(g.file));
480
+ }
481
+ const attempts = [];
482
+ const needsSetup = [];
483
+ const skipped = [];
484
+ const generatedFiles = [];
485
+ const declaredDeps = readDeclaredDeps(sourceRoot);
486
+ let proven = 0;
487
+ let attempted = 0;
488
+ for (let start = 0; start < candidates.length && attempted < genBudget; start += GEN_WINDOW) {
489
+ const window = candidates.slice(start, start + GEN_WINDOW);
490
+ const need = genBudget - attempted;
491
+ const windowIds = window.map((g) => g.id);
492
+ const gen = await generate(graph, { target_ids: windowIds, limit: Math.min(windowIds.length, need) }, provider, reader, clock);
493
+ const tests = gen.generated_tests;
494
+ // Global start offset so filenames stay unique across windows — runHintsFor
495
+ // otherwise resets its index to 0 per window and same-slug targets collide.
496
+ const hints = runHintsFor(tests, sourceRoot, start);
497
+ for (let i = 0; i < tests.length && attempted < genBudget; i++) {
498
+ const test = tests[i];
499
+ const hint = hints[i];
500
+ if (!hint.prove_run) {
501
+ // Non-TS/JS target or no resolvable code symbol → not attemptable.
502
+ skipped.push({
503
+ target_symbol: hint.target_symbol_external_id,
504
+ title: test.title,
505
+ reason: hint.target_symbol_external_id
506
+ ? "Target is not TS/JS; dynamic proof supports TS/JS CodeSymbol targets only."
507
+ : "No resolvable TS/JS code-symbol target to prove."
508
+ });
509
+ continue;
510
+ }
511
+ const { target_symbol, replacement, runner } = hint.prove_run.args;
512
+ const targetFileRel = symbolFile(target_symbol);
513
+ // R-1 sibling dedup (cross-lane): a same-file target already hit an import-time env root
514
+ // cause → a fresh generated test importing the same module fails identically. Skip it
515
+ // WITHOUT generating/writing/running or consuming the attempt budget.
516
+ const blocked = importTimeBlocked.get(dedupKey(undefined, targetFileRel));
517
+ if (blocked) {
518
+ const attempt = dedupedAttempt(target_symbol, "", targetFileRel, blocked);
519
+ attempts.push(attempt);
520
+ needsSetup.push(attempt);
521
+ continue;
522
+ }
523
+ const filename = basename(hint.prove_run.args.test_path);
524
+ let abs;
525
+ try {
526
+ abs = containedGeneratedPath(sourceRoot, filename);
527
+ }
528
+ catch (e) {
529
+ skipped.push({ target_symbol, title: test.title, reason: `Generated path rejected (escapes ${GENERATED_DIR}/): ${redactSecrets(errMsg(e))}` });
530
+ continue;
531
+ }
532
+ const writeRel = `${GENERATED_DIR}/${filename}`;
533
+ // The generator grounds imports at the source/suggested dir; autoProve writes
534
+ // to orangepro_generated/. Re-point relative specifiers at the target source
535
+ // file so they resolve from the write location (`./x` → `../src/x`).
536
+ const body = rewriteRelativeImports(test.body, symbolFile(target_symbol), GENERATED_DIR);
537
+ // A "non-runnable" verdict is usually just import LOCATION, which the rewrite
538
+ // fixes. Re-validate against the ACTUAL write location: skip only if locals
539
+ // still won't resolve (a genuine setup gap the oracle could never honestly close).
540
+ if (test.runnable === false && unresolvedLocalImports(body, abs, sourceRoot, declaredDeps).length > 0) {
541
+ skipped.push({
542
+ target_symbol,
543
+ title: test.title,
544
+ reason: `Generator returned a non-runnable draft${test.unresolved_reason ? `: ${test.unresolved_reason}` : "."}`
545
+ });
546
+ continue;
547
+ }
548
+ if (existsSync(abs)) {
549
+ // Guardrail: never overwrite an existing file.
550
+ skipped.push({ target_symbol, title: test.title, reason: `Existing file ${writeRel} not overwritten.` });
551
+ continue;
552
+ }
553
+ mkdirSync(dirname(abs), { recursive: true });
554
+ writeFileSync(abs, `${GENERATED_HEADER}\n${body}${body.endsWith("\n") ? "" : "\n"}`, "utf8");
555
+ generatedFiles.push(writeRel);
556
+ attempted++;
557
+ // R-2: inject the node:sqlite env profile when the TARGET source references the builtin.
558
+ const testEnv = experimentalSqliteTestEnv(reader, targetFileRel);
559
+ let result;
560
+ try {
561
+ result = proveLoop(root, {
562
+ target_symbol,
563
+ source: sourceRoot,
564
+ test_path: writeRel,
565
+ replacement,
566
+ runner,
567
+ // See lane 1: link node_modules so a generated test importing a repo dep can boot.
568
+ link_node_modules: true,
569
+ ...(testEnv ? { test_env: testEnv } : {}),
570
+ run_id: `auto-prove-${start + i + 1}`
571
+ }, proveDeps);
572
+ }
573
+ catch (e) {
574
+ const attempt = {
575
+ target_symbol,
576
+ test_path: writeRel,
577
+ classification: "needs_setup",
578
+ reason: `Proof could not run: ${redactSecrets(errMsg(e))}`
579
+ };
580
+ attempts.push(attempt);
581
+ needsSetup.push(attempt);
582
+ continue;
583
+ }
584
+ const { classification, reason, category } = classifyProof(result, { sourceRoot, targetFileRel });
585
+ const attempt = { target_symbol, test_path: writeRel, classification, reason, category };
586
+ attempts.push(attempt);
587
+ if (classification === "proven")
588
+ proven++;
589
+ else if (classification === "needs_setup") {
590
+ needsSetup.push(attempt);
591
+ // Cache an import-time root cause so same-file siblings dedup instead of re-running.
592
+ if (category && IMPORT_TIME_CATEGORIES.has(category)) {
593
+ importTimeBlocked.set(dedupKey(undefined, targetFileRel), { category, reason: reason ?? "" });
594
+ }
595
+ }
596
+ // non_killing stays in `attempts` only — an honest skip, never Proven.
597
+ }
598
+ // A window that produced nothing (transient generation hiccup) must NOT abandon
599
+ // lower-ranked candidates: the outer loop already terminates at pool exhaustion
600
+ // or when the attempt budget (autoLimit) is reached.
601
+ }
602
+ // Merge the existing-tests lane (ran first, no key) with the generation lane.
603
+ const totalProven = ex.proven + proven;
604
+ const totalAttempted = ex.attempted + attempted;
605
+ const status = totalProven > 0 ? "proven-run" : totalAttempted > 0 ? "ran-no-proof" : "no-targets";
606
+ return {
607
+ ran: true,
608
+ status,
609
+ attempted: totalAttempted,
610
+ proven: totalProven,
611
+ needs_setup: [...ex.needsSetup, ...needsSetup],
612
+ skipped,
613
+ generated_files: generatedFiles,
614
+ attempts: [...ex.attempts, ...attempts]
615
+ };
616
+ }
617
+ function errMsg(e) {
618
+ const msg = e instanceof Error ? e.message : String(e);
619
+ return msg.split("\n", 1)[0] ?? msg;
620
+ }