@orangepro/orangepro-mcp 0.2.34 → 0.2.35

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
@@ -347,6 +347,7 @@ Analysis, scoring, and proof need no model key. Generation does.
347
347
  | Ollama (local, no key) | `OLLAMA_BASE_URL` (optional: `OLLAMA_MODEL`) |
348
348
 
349
349
  Auto-detect order: OpenAI → Ollama → Anthropic. Override with `--provider` and `--model`.
350
+ The defaults are `gpt-5.3-codex` for OpenAI and `claude-sonnet-5` for Anthropic.
350
351
 
351
352
  Run `opro setup` to configure interactively. Keys stay in your environment — never written to graph, config, or artifacts.
352
353
 
@@ -1312,6 +1312,10 @@ export function analyzeRepo(root, opts = {}) {
1312
1312
  }
1313
1313
  }
1314
1314
  // A non-imported (local/param) qualifier is NOT anchored — no edge.
1315
+ // Retain the callee NAME as a fact on the caller (Fix B): `t.adminClient.
1316
+ // DeleteWorkflowExecution` is invisible as an edge (external interface) but
1317
+ // is exactly the kind of sink risk scoring must be able to see. Names only —
1318
+ // no node, no edge, no evidence claim.
1315
1319
  }
1316
1320
  }
1317
1321
  else if (c.via === "injected" && c.injectedType) {
@@ -1835,6 +1839,36 @@ export function analyzeRepo(root, opts = {}) {
1835
1839
  addImportBinding(rel, i.local, { ...(targetRel ? { targetRel } : {}), ...(targetDir ? { targetDir } : {}) }, i.imported, i.kind);
1836
1840
  }
1837
1841
  }
1842
+ // Go package-level method index (dir → "Recv.M" → file) for receiver calls that
1843
+ // land in a sibling file of the same package.
1844
+ const goPkgMethods = new Map();
1845
+ for (const [f, syms] of symbolsByFile) {
1846
+ if (!f.endsWith(".go") || f.endsWith("_test.go"))
1847
+ continue;
1848
+ const dir = f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "";
1849
+ let m = goPkgMethods.get(dir);
1850
+ if (!m) {
1851
+ m = new Map();
1852
+ goPkgMethods.set(dir, m);
1853
+ }
1854
+ for (const sname of syms)
1855
+ if (sname.includes("."))
1856
+ m.set(sname, f);
1857
+ }
1858
+ const goPackageMethodFile = (rel, member) => {
1859
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
1860
+ return goPkgMethods.get(dir)?.get(member);
1861
+ };
1862
+ const externalCalleesByCaller = new Map();
1863
+ const recordExternalCallee = (callerId, name) => {
1864
+ let set = externalCalleesByCaller.get(callerId);
1865
+ if (!set) {
1866
+ set = new Set();
1867
+ externalCalleesByCaller.set(callerId, set);
1868
+ }
1869
+ if (set.size < 32)
1870
+ set.add(name);
1871
+ };
1838
1872
  for (const [rel, { language, structure }] of nonTsStructureByFile) {
1839
1873
  const localSyms = symbolsByFile.get(rel);
1840
1874
  if (!localSyms)
@@ -1869,7 +1903,28 @@ export function analyzeRepo(root, opts = {}) {
1869
1903
  if (shadowed.has(rootQualifier))
1870
1904
  continue;
1871
1905
  const b = imports?.get(rootQualifier);
1872
- if (language === "go" && b?.kind === "module" && b.targetDir) {
1906
+ if (language === "go" && c.receiverVar && c.receiverType && c.qualifier === c.receiverVar) {
1907
+ // Go receiver-method call: inside `func (t *task) Run()`, `t.validate()` calls
1908
+ // `task.validate`. The receiver's type is DECLARED in the method signature, so
1909
+ // this is exact resolution: same file first, then the same package (methods of
1910
+ // one type may span files; Go forbids duplicate method names on a type, so a
1911
+ // package-level hit is unambiguous).
1912
+ const member = `${c.receiverType}.${c.callee}`;
1913
+ if (localSyms.has(member))
1914
+ emitCall(callerId, `sym:${rel}#${member}`, rel);
1915
+ else {
1916
+ const pkgRel = goPackageMethodFile(rel, member);
1917
+ if (pkgRel)
1918
+ emitCall(callerId, `sym:${pkgRel}#${member}`, rel);
1919
+ }
1920
+ }
1921
+ else if (language === "go" && !b && c.receiverVar && rootQualifier === c.receiverVar) {
1922
+ // `t.client.Delete(...)`: a call through a receiver FIELD to an external or
1923
+ // interface method — no resolvable node. Retain the callee NAME on the caller
1924
+ // (Fix B) so scoring can see sinks like `DeleteWorkflowExecution`. Names only.
1925
+ recordExternalCallee(callerId, `${c.qualifier}.${c.callee}`);
1926
+ }
1927
+ else if (language === "go" && b?.kind === "module" && b.targetDir) {
1873
1928
  const targetRel = uniqueGoPackageSymbol(b.targetDir, c.callee);
1874
1929
  if (targetRel)
1875
1930
  emitCall(callerId, `sym:${targetRel}#${c.callee}`, rel);
@@ -1898,6 +1953,16 @@ export function analyzeRepo(root, opts = {}) {
1898
1953
  }
1899
1954
  }
1900
1955
  }
1956
+ // Fix B: persist retained callee names on their caller symbols (names only).
1957
+ if (externalCalleesByCaller.size > 0) {
1958
+ const byId = new Map(nodes.map((n) => [n.external_id, n]));
1959
+ for (const [callerId, names] of externalCalleesByCaller) {
1960
+ const n = byId.get(callerId);
1961
+ if (!n)
1962
+ continue;
1963
+ n.properties = { ...(n.properties ?? {}), external_callees: [...names].sort() };
1964
+ }
1965
+ }
1901
1966
  const seenGoProof = new Set();
1902
1967
  const proofVerifiedAt = scanStartMs;
1903
1968
  for (const [testRel, structure] of goTestStructureByFile) {
@@ -392,6 +392,11 @@ function functionName(node, language) {
392
392
  * behavior). NOT used for test-name extraction — suite test names must stay bare
393
393
  * (`^Test` gate at extractGoProofCalls).
394
394
  */
395
+ function goReceiverVarName(node) {
396
+ const param = node.childForFieldName("receiver")?.namedChild(0);
397
+ const name = param?.childForFieldName("name");
398
+ return name?.type === "identifier" ? name.text : undefined;
399
+ }
395
400
  function goReceiverBaseName(node) {
396
401
  const param = node.childForFieldName("receiver")?.namedChild(0);
397
402
  let t = param?.childForFieldName("type") ?? undefined;
@@ -1494,6 +1499,7 @@ export function extractTreeSitterStructure(content, language) {
1494
1499
  return { imports: [], calls: [] };
1495
1500
  const root = tree.rootNode;
1496
1501
  const calls = [];
1502
+ const callerReceivers = new Map();
1497
1503
  const isCallableNode = (node) => (language === "java" && node.type === "method_declaration") ||
1498
1504
  (language === "python" && node.type === "function_definition") ||
1499
1505
  (language === "go" && (node.type === "function_declaration" || node.type === "method_declaration")) ||
@@ -1517,8 +1523,12 @@ export function extractTreeSitterStructure(content, language) {
1517
1523
  // qualified caller so Layer-1 edges keep matching the emitted symbol.
1518
1524
  if (name && language === "go" && node.type === "method_declaration") {
1519
1525
  const recv = goReceiverBaseName(node);
1520
- if (recv)
1526
+ if (recv) {
1521
1527
  name = `${recv}.${name}`;
1528
+ const rv = goReceiverVarName(node);
1529
+ if (rv)
1530
+ callerReceivers.set(name, { receiverVar: rv, receiverType: recv });
1531
+ }
1522
1532
  }
1523
1533
  if (name) {
1524
1534
  nextCaller = name;
@@ -1529,7 +1539,7 @@ export function extractTreeSitterStructure(content, language) {
1529
1539
  }
1530
1540
  const parts = callParts(node, language);
1531
1541
  if (nextCaller && parts) {
1532
- calls.push({ caller: nextCaller, ...parts, shadowed: [...nextShadowed] });
1542
+ calls.push({ caller: nextCaller, ...parts, shadowed: [...nextShadowed], ...(callerReceivers.get(nextCaller) ?? {}) });
1533
1543
  }
1534
1544
  for (const child of namedChildren(node))
1535
1545
  visit(child, nextCaller, nextShadowed, nextInsideFunction);
package/dist/local/cli.js CHANGED
@@ -94,7 +94,7 @@ Usage:
94
94
  opro score [--json]
95
95
  opro gaps [--limit 10] [--min-priority medium] [--json]
96
96
  # also returns top_risk_gaps: unproven code symbols ranked by OrangePro Risk Score (P × I × D)
97
- opro record --target-symbol sym:file#Symbol [--test path] [--agent-pass true|false] [--evidence-ids id1,id2] [--provider openai] [--model gpt-4.1] [--prompt-version v1] [--json]
97
+ opro record --target-symbol sym:file#Symbol [--test path] [--agent-pass true|false] [--evidence-ids id1,id2] [--provider openai] [--model gpt-5.3-codex] [--prompt-version v1] [--json]
98
98
  # record writes static reprove diagnostics only; public Proven requires \`opro prove\`
99
99
  opro prove --target-symbol sym:file#Symbol --test path --replacement 'return ...;' [--target-file path] [--method name] [--replacement-mode return-json|promise-json] [--runner auto|vitest|jest|mocha|pytest] [--link-node-modules] [--json]
100
100
  # runs the dynamic targeted-proof oracle and writes a metadata-only ledger certificate only when baseline-green → mutant assertion-fail closes
@@ -221,6 +221,8 @@ async function main() {
221
221
  }
222
222
  if (res.coverage_report_path)
223
223
  out(` coverage report: ${res.coverage_report_path}`);
224
+ if (res.generation_diagnostics_path)
225
+ out(` generation diagnostics: ${res.generation_diagnostics_path}`);
224
226
  out(` RTM: ${res.rtm.rtm_path}${res.rtm.rows.length < res.rtm.summary.total ? ` (capped ${res.rtm.rows.length}/${res.rtm.summary.total} rows)` : ""}`);
225
227
  out(` Dynamically Proven: ${res.rtm.summary.proven}/${res.rtm.summary.total} (static map covers all ${res.rtm.summary.total}; dynamic proof verifies the top ${res.auto_prove.attempted || "few"})`);
226
228
  if (res.rtm.summary.total > 0 && res.rtm.summary.proven === 0) {
@@ -2294,8 +2294,88 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2294
2294
  if (existingGeneratedTitles.length) {
2295
2295
  gc.ctx.existing_tests = dedupe([...gc.ctx.existing_tests, ...existingGeneratedTitles]);
2296
2296
  }
2297
+ const manualDraftForScenario = (scenario, reason) => {
2298
+ const manualBody = sanitizeGeneratedBody([
2299
+ `Scenario: ${scenario.title}`,
2300
+ ...(scenario.steps && scenario.steps.length
2301
+ ? ["Steps:", ...scenario.steps.map((st, n) => ` ${n + 1}. ${st}`)]
2302
+ : []),
2303
+ ...(scenario.test_data ? [`Test data: ${scenario.test_data}`] : []),
2304
+ ...(scenario.assertion_targets.length ? [`Expected: ${scenario.assertion_targets.join("; ")}`] : []),
2305
+ ...(scenario.rationale ? [`Why this test: ${scenario.rationale}`] : []),
2306
+ "",
2307
+ `Blocked by: ${reason.split(" — ")[0]}`,
2308
+ `Fix: ${generatedDraftRemediation(reason)}`
2309
+ ].join("\n"), gc.ctx.source_excerpts, "//").body;
2310
+ return {
2311
+ id: `${run_id}-t${generated.length + 1}`,
2312
+ run_id,
2313
+ title: `${gc.ctx.behavior_title} — ${scenario.title}`,
2314
+ test_type: gc.ctx.test_layer,
2315
+ framework_hint: framework,
2316
+ body: manualBody,
2317
+ bucket: bucketForV5Scenario(scenario),
2318
+ prompt_version: PROMPT_VERSION_V5,
2319
+ grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
2320
+ weak_evidence_used: false,
2321
+ target_symbol_external_id: behavior.external_id,
2322
+ ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
2323
+ runnable: false,
2324
+ unresolved_reason: reason
2325
+ };
2326
+ };
2327
+ const emitManualPlanningFallback = (planningReason) => {
2328
+ if (!opts.manual_planning_fallback)
2329
+ return;
2330
+ const normalizedExistingTitles = new Set(existingGeneratedTitles.map((title) => title.trim().toLowerCase()));
2331
+ const candidates = [
2332
+ {
2333
+ id: 1,
2334
+ title: "Validate the observable contract through the nearest public entry point",
2335
+ concern: "contract",
2336
+ technique: "contract_verification",
2337
+ rationale: "Preserve a reviewable contract test intent when the model planner returns no accepted scenario.",
2338
+ assertion_targets: ["The documented result, state change, and externally visible side effects are correct"],
2339
+ steps: [
2340
+ "Arrange valid inputs and the minimum required collaborators for this flow",
2341
+ "Exercise the flow through its nearest public entry point",
2342
+ "Assert the observable result, state transition, and side effects"
2343
+ ],
2344
+ complexity: "intermediate",
2345
+ risk_rank: 1
2346
+ },
2347
+ {
2348
+ id: 2,
2349
+ title: "Reject invalid or boundary input without partial side effects",
2350
+ concern: "failure_recovery",
2351
+ technique: "error_guessing",
2352
+ rationale: "Preserve a reviewable failure-path intent for a priority flow even when model planning fails.",
2353
+ assertion_targets: ["The failure is explicit and no partial state or unintended side effect remains"],
2354
+ steps: [
2355
+ "Arrange an invalid, missing, or boundary input relevant to this flow",
2356
+ "Exercise the same public entry path",
2357
+ "Assert a clear failure outcome and verify that partial side effects were not retained"
2358
+ ],
2359
+ complexity: "intermediate",
2360
+ risk_rank: 2
2361
+ }
2362
+ ];
2363
+ const remaining = Math.max(0, limit - generated.length);
2364
+ const selectedFallbacks = candidates.filter((scenario) => {
2365
+ const scenarioTitle = scenario.title.trim().toLowerCase();
2366
+ const fullTitle = `${gc.ctx.behavior_title} — ${scenario.title}`.trim().toLowerCase();
2367
+ return !normalizedExistingTitles.has(scenarioTitle) && !normalizedExistingTitles.has(fullTitle);
2368
+ }).slice(0, remaining);
2369
+ const reason = `${planningReason} Manual fallback retained because this was the final bounded planning attempt; it is not runnable generated code.`;
2370
+ for (const scenario of selectedFallbacks)
2371
+ generated.push(manualDraftForScenario(scenario, reason));
2372
+ if (selectedFallbacks.length) {
2373
+ warnings.push(`Retained ${selectedFallbacks.length} manual planning fallback(s) for "${gc.ctx.behavior_title}" after the final bounded planning attempt failed.`);
2374
+ }
2375
+ };
2297
2376
  reportProgress(`Planning "${gc.ctx.behavior_title}" [v5]…`);
2298
2377
  let scenarios = [];
2378
+ let emptyScenarioReason = "V5 planning returned no missing scenarios.";
2299
2379
  // Transport first: a network/timeout failure is NOT malformed JSON, so it does
2300
2380
  // not warrant a repair pass — fail closed and emit no test.
2301
2381
  let rawPlan;
@@ -2316,6 +2396,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2316
2396
  reason: `V5 planning call failed: ${msg}`,
2317
2397
  needed: ["a reachable model provider"]
2318
2398
  });
2399
+ emitManualPlanningFallback(`V5 planning call failed: ${msg}.`);
2319
2400
  continue;
2320
2401
  }
2321
2402
  try {
@@ -2323,6 +2404,9 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2323
2404
  scenarios = result.scenarios;
2324
2405
  if (result.dropped > 0) {
2325
2406
  warnings.push(`Dropped ${result.dropped} invalid v5 planned scenario(s) for "${gc.ctx.behavior_title}": ${result.dropSummary.join("; ")}.`);
2407
+ if (result.scenarios.length === 0) {
2408
+ emptyScenarioReason = `V5 planning returned only invalid scenarios: ${result.dropSummary.join("; ")}.`;
2409
+ }
2326
2410
  }
2327
2411
  }
2328
2412
  catch (parseErr) {
@@ -2341,6 +2425,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2341
2425
  reason: `V5 planning JSON had no recoverable scenario array (repair would invent): ${parseMsg}`,
2342
2426
  needed: ["valid JSON planned scenarios"]
2343
2427
  });
2428
+ emitManualPlanningFallback(`V5 planning JSON had no recoverable scenario array: ${parseMsg}.`);
2344
2429
  continue;
2345
2430
  }
2346
2431
  try {
@@ -2363,6 +2448,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2363
2448
  reason: "V5 planning repair produced only invented scenarios not tied to the original output.",
2364
2449
  needed: ["valid JSON planned scenarios"]
2365
2450
  });
2451
+ emitManualPlanningFallback("V5 planning repair produced no scenario tied to the original response.");
2366
2452
  continue;
2367
2453
  }
2368
2454
  scenarios = tiedBack;
@@ -2381,6 +2467,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2381
2467
  reason: `V5 planning JSON was malformed and could not be repaired: ${repairMsg}`,
2382
2468
  needed: ["valid JSON planned scenarios"]
2383
2469
  });
2470
+ emitManualPlanningFallback(`V5 planning JSON was malformed and repair failed: ${repairMsg}.`);
2384
2471
  continue;
2385
2472
  }
2386
2473
  }
@@ -2395,15 +2482,19 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2395
2482
  const duplicateCount = beforeDuplicateFilter - scenarios.length;
2396
2483
  if (duplicateCount > 0) {
2397
2484
  warnings.push(`Dropped ${duplicateCount} already-generated v5 scenario(s) for "${gc.ctx.behavior_title}" during top-up.`);
2485
+ if (scenarios.length === 0) {
2486
+ emptyScenarioReason = "V5 planning returned only scenarios already generated for this flow.";
2487
+ }
2398
2488
  }
2399
2489
  }
2400
2490
  if (scenarios.length === 0) {
2401
2491
  missing.push({
2402
2492
  external_id: behavior.external_id,
2403
2493
  title: gc.ctx.behavior_title,
2404
- reason: "V5 planning returned no missing scenarios.",
2494
+ reason: emptyScenarioReason,
2405
2495
  needed: ["a distinct uncovered scenario"]
2406
2496
  });
2497
+ emitManualPlanningFallback(emptyScenarioReason);
2407
2498
  continue;
2408
2499
  }
2409
2500
  const remainingSlots = Math.max(1, limit - generated.length);
@@ -2413,36 +2504,6 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2413
2504
  // with several scenarios, leaving later high-risk behaviors untouched.
2414
2505
  const targetLimit = explicitMulti ? Math.max(1, Math.floor(remainingSlots / remainingTargets)) : remainingSlots;
2415
2506
  const selected = scenarios.slice(0, targetLimit);
2416
- const manualDraftForScenario = (scenario, reason) => {
2417
- const manualBody = sanitizeGeneratedBody([
2418
- `Scenario: ${scenario.title}`,
2419
- ...(scenario.steps && scenario.steps.length
2420
- ? ["Steps:", ...scenario.steps.map((st, n) => ` ${n + 1}. ${st}`)]
2421
- : []),
2422
- ...(scenario.test_data ? [`Test data: ${scenario.test_data}`] : []),
2423
- ...(scenario.assertion_targets.length ? [`Expected: ${scenario.assertion_targets.join("; ")}`] : []),
2424
- ...(scenario.rationale ? [`Why this test: ${scenario.rationale}`] : []),
2425
- "",
2426
- `Blocked by: ${reason.split(" — ")[0]}`,
2427
- `Fix: ${generatedDraftRemediation(reason)}`
2428
- ].join("\n"), gc.ctx.source_excerpts, "//").body;
2429
- return {
2430
- id: `${run_id}-t${generated.length + 1}`,
2431
- run_id,
2432
- title: `${gc.ctx.behavior_title} — ${scenario.title}`,
2433
- test_type: gc.ctx.test_layer,
2434
- framework_hint: framework,
2435
- body: manualBody,
2436
- bucket: bucketForV5Scenario(scenario),
2437
- prompt_version: PROMPT_VERSION_V5,
2438
- grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
2439
- weak_evidence_used: false,
2440
- target_symbol_external_id: behavior.external_id,
2441
- ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
2442
- runnable: false,
2443
- unresolved_reason: reason
2444
- };
2445
- };
2446
2507
  const completions = [];
2447
2508
  try {
2448
2509
  reportProgress(`Generating "${gc.ctx.behavior_title}" [v5 batch: ${selected.length}]…`);
@@ -22,7 +22,7 @@ async function postJson(fetchImpl, url, headers, body, timeoutMs = DEFAULT_TIMEO
22
22
  // Map the cryptic AbortError ("This operation was aborted") to an actionable
23
23
  // timeout message — the #1 cause is a reasoning model thinking past the cap.
24
24
  if (e?.name === "AbortError") {
25
- throw new Error(`Model call timed out after ${Math.round(timeoutMs / 1000)}s. Reasoning models (gpt-5/o-series) can spend ` +
25
+ throw new Error(`Model call timed out after ${Math.round(timeoutMs / 1000)}s. Reasoning or adaptive-thinking models can spend ` +
26
26
  `minutes on hidden reasoning before responding; retry, or try a smaller --limit or a different model.`);
27
27
  }
28
28
  throw e;
@@ -36,6 +36,8 @@ async function postJson(fetchImpl, url, headers, body, timeoutMs = DEFAULT_TIMEO
36
36
  // `temperature`. Used only to SEED the request; the adapter still self-corrects
37
37
  // from the API's own error, so this list need not be exhaustive or current.
38
38
  const REASONING_MODEL = /(?:gpt-5|^o[0-9]|[-/]o[0-9])/i;
39
+ const RESPONSES_API_MODEL = /^gpt-5\.3-codex(?:-|$)/i;
40
+ const ANTHROPIC_ADAPTIVE_MODEL = /^claude-(?:sonnet-[5-9](?:-|$)|opus-(?:4-(?:7|8)|[5-9])(?:-|$)|fable-[5-9](?:-|$))/i;
39
41
  /**
40
42
  * Reasoning models spend minutes on hidden reasoning before the (non-streaming)
41
43
  * response returns — the 60s default killed live gpt-5 runs mid-generation
@@ -45,7 +47,9 @@ const REASONING_MODEL = /(?:gpt-5|^o[0-9]|[-/]o[0-9])/i;
45
47
  const REASONING_TIMEOUT_MS = 600_000;
46
48
  /** Per-call timeout: generous for reasoning models, default for the rest. */
47
49
  export function providerTimeoutMs(model) {
48
- return REASONING_MODEL.test(model) ? REASONING_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
50
+ return REASONING_MODEL.test(model) || ANTHROPIC_ADAPTIVE_MODEL.test(model)
51
+ ? REASONING_TIMEOUT_MS
52
+ : DEFAULT_TIMEOUT_MS;
49
53
  }
50
54
  /**
51
55
  * From a 400, determine which token param the model actually WANTS. OpenAI's error
@@ -89,6 +93,32 @@ export class OpenAICompatibleProvider {
89
93
  const modern = REASONING_MODEL.test(this.cfg.model);
90
94
  // Reasoning models spend tokens on hidden reasoning, so give them more room.
91
95
  let maxTokens = req.maxTokens ?? (modern ? 4000 : 900);
96
+ if (RESPONSES_API_MODEL.test(this.cfg.model)) {
97
+ let retriedLength = false;
98
+ for (;;) {
99
+ const data = (await postJson(this.fetchImpl, `${this.cfg.baseUrl}/responses`, { Authorization: `Bearer ${this.cfg.apiKey ?? ""}` }, {
100
+ model: this.cfg.model,
101
+ instructions: req.system,
102
+ input: req.user,
103
+ max_output_tokens: maxTokens,
104
+ store: false
105
+ }, providerTimeoutMs(this.cfg.model)));
106
+ const truncated = data.status === "incomplete" && data.incomplete_details?.reason === "max_output_tokens";
107
+ if (truncated && !retriedLength) {
108
+ retriedLength = true;
109
+ maxTokens *= 4;
110
+ continue;
111
+ }
112
+ if (truncated) {
113
+ throw new Error("Model output was truncated at the token limit after one retry; no generated code was accepted.");
114
+ }
115
+ return (data.output ?? [])
116
+ .flatMap((item) => item.type === "message" ? (item.content ?? []) : [])
117
+ .filter((content) => content.type === "output_text")
118
+ .map((content) => content.text ?? "")
119
+ .join("");
120
+ }
121
+ }
92
122
  // Seed from the model name, then self-correct from the API's explicit directive.
93
123
  let tokenParam = modern ? "max_completion_tokens" : "max_tokens";
94
124
  let sendTemperature = !modern;
@@ -177,16 +207,21 @@ export class AnthropicProvider {
177
207
  return this.cfg.model;
178
208
  }
179
209
  async complete(req) {
180
- let maxTokens = req.maxTokens ?? 900;
210
+ const adaptive = ANTHROPIC_ADAPTIVE_MODEL.test(this.cfg.model);
211
+ let maxTokens = req.maxTokens ?? (adaptive ? 4000 : 900);
212
+ if (adaptive)
213
+ maxTokens = Math.max(maxTokens, 4000);
181
214
  let retriedLength = false;
182
215
  for (;;) {
183
- const data = (await postJson(this.fetchImpl, `${this.cfg.baseUrl}/messages`, { "x-api-key": this.cfg.apiKey ?? "", "anthropic-version": "2023-06-01" }, {
216
+ const body = {
184
217
  model: this.cfg.model,
185
218
  max_tokens: maxTokens,
186
- temperature: req.temperature ?? 0.2,
187
219
  system: req.system,
188
220
  messages: [{ role: "user", content: req.user }]
189
- }, providerTimeoutMs(this.cfg.model)));
221
+ };
222
+ if (!adaptive)
223
+ body.temperature = req.temperature ?? 0.2;
224
+ const data = (await postJson(this.fetchImpl, `${this.cfg.baseUrl}/messages`, { "x-api-key": this.cfg.apiKey ?? "", "anthropic-version": "2023-06-01" }, body, providerTimeoutMs(this.cfg.model)));
190
225
  if (data.stop_reason === "max_tokens" && !retriedLength) {
191
226
  retriedLength = true;
192
227
  maxTokens *= 4;
@@ -5,13 +5,15 @@
5
5
  */
6
6
  export const SUPPORTED_MODELS = {
7
7
  openai: [
8
- { model: "gpt-4.1", note: "strong general (recommended)" },
8
+ { model: "gpt-5.3-codex", note: "coding and test generation (recommended)" },
9
+ { model: "gpt-4.1", note: "strong non-reasoning model" },
9
10
  { model: "gpt-4o", note: "fast, multimodal" },
10
11
  { model: "gpt-5", note: "reasoning — uses max_completion_tokens" },
11
12
  { model: "gpt-4.1-mini", note: "cheap — smoke tests only" }
12
13
  ],
13
14
  anthropic: [
14
- { model: "claude-sonnet-4-6", note: "recommended" },
15
+ { model: "claude-sonnet-5", note: "default and recommended" },
16
+ { model: "claude-sonnet-4-6", note: "previous Sonnet fallback" },
15
17
  { model: "claude-opus-4-8", note: "deepest reasoning" },
16
18
  { model: "claude-haiku-4-5", note: "fast, cheap" }
17
19
  ],
@@ -76,7 +76,7 @@ export function resolveProviderConfig(env = process.env, override = {}) {
76
76
  return null;
77
77
  return {
78
78
  provider: "openai",
79
- model: override.model || env.OPENAI_MODEL || "gpt-4.1",
79
+ model: override.model || env.OPENAI_MODEL || "gpt-5.3-codex",
80
80
  baseUrl: (env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/+$/, ""),
81
81
  apiKey: env.OPENAI_API_KEY
82
82
  };
@@ -86,7 +86,7 @@ export function resolveProviderConfig(env = process.env, override = {}) {
86
86
  return null;
87
87
  return {
88
88
  provider: "anthropic",
89
- model: override.model || env.ANTHROPIC_MODEL || "claude-sonnet-4-6",
89
+ model: override.model || env.ANTHROPIC_MODEL || "claude-sonnet-5",
90
90
  baseUrl: (env.ANTHROPIC_BASE_URL || "https://api.anthropic.com/v1").replace(/\/+$/, ""),
91
91
  apiKey: env.ANTHROPIC_API_KEY
92
92
  };
@@ -1658,6 +1658,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1658
1658
  ? "Test generation budget is 0 (--generate-limit 0)."
1659
1659
  : undefined
1660
1660
  };
1661
+ let generationDiagnosticsPath;
1661
1662
  if (!opts.noAuto && opts.ai !== false && generationLimit > 0 && !generationProviderConfigured) {
1662
1663
  generationResult = {
1663
1664
  ...generationResult,
@@ -1678,11 +1679,12 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1678
1679
  tests.push(test);
1679
1680
  generatedTestsByTarget.set(targetId, tests);
1680
1681
  }
1681
- const targetPlans = rankPriorityGaps(graphForGeneration, {
1682
+ const rankedGenerationGaps = rankPriorityGaps(graphForGeneration, {
1682
1683
  repoRoot: root,
1683
1684
  limit: generationLimit,
1684
1685
  provenIds: provenSymbolIds(graphForGeneration, loadLedger(root))
1685
- })
1686
+ });
1687
+ const targetPlans = rankedGenerationGaps
1686
1688
  .map((gap) => {
1687
1689
  const existingTests = generatedTestsByTarget.get(gap.id) ?? [];
1688
1690
  return {
@@ -1703,9 +1705,20 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1703
1705
  reportProgress(`generate: filling test gaps for ${targetPlans.length} priority flow(s)`, { current: 6, total: 8 });
1704
1706
  const generatedDrafts = [];
1705
1707
  const incompleteTargets = [];
1708
+ const diagnosticsStartedAt = deps.clock();
1709
+ const flowDiagnostics = [];
1706
1710
  for (const target of targetPlans) {
1707
1711
  let remaining = target.deficit;
1708
1712
  const existingTitles = target.existingTests.map((test) => test.title);
1713
+ const flowDiagnostic = {
1714
+ target_symbol_external_id: target.id,
1715
+ title: graphForGeneration.nodes.find((node) => node.external_id === target.id)?.title ?? target.id,
1716
+ existing_tests_before_run: target.existingTests.length,
1717
+ requested_slots: target.deficit,
1718
+ attempts: [],
1719
+ final_tests: target.existingTests.length,
1720
+ remaining_shortfall: target.deficit
1721
+ };
1709
1722
  // A provider can return one accepted scenario when two were requested.
1710
1723
  // Make at most one bounded follow-up for the remaining slot, carrying
1711
1724
  // prior titles so the planner cannot silently duplicate the first test.
@@ -1716,20 +1729,40 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1716
1729
  limit: remaining,
1717
1730
  pin_unchanged: false,
1718
1731
  existing_generated_test_titles: existingTitles,
1732
+ manual_planning_fallback: attempt === 1,
1719
1733
  // The offline deterministic stand-in emits the established v2 scaffold; v5 is
1720
1734
  // a two-phase model planning protocol and must not be selected implicitly for it.
1721
1735
  prompt_version: opts.promptVersion ?? (deterministicGeneration ? "v2" : "v5")
1722
1736
  }, providerDeps);
1723
1737
  const freshForTarget = generated.generated_tests.filter((test) => test.target_symbol_external_id === target.id && !test.pinned);
1738
+ const manualFallbacks = freshForTarget.filter((test) => test.runnable === false && test.unresolved_reason?.includes("Manual fallback retained")).length;
1739
+ flowDiagnostic.attempts.push({
1740
+ attempt: attempt + 1,
1741
+ provider: generated.model_provider,
1742
+ model: generated.model_name,
1743
+ requested_slots: remaining,
1744
+ persisted_test_intents: freshForTarget.length,
1745
+ model_planned_tests: freshForTarget.length - manualFallbacks,
1746
+ runnable_tests: freshForTarget.filter((test) => test.runnable !== false).length,
1747
+ manual_fallbacks: manualFallbacks,
1748
+ missing_evidence: generated.missing_evidence.map((item) => ({
1749
+ reason: redactSecrets(item.reason).slice(0, 2_000),
1750
+ needed: item.needed.map((needed) => redactSecrets(needed).slice(0, 500))
1751
+ })),
1752
+ warnings: generated.warnings.map((warning) => redactSecrets(warning).slice(0, 2_000))
1753
+ });
1724
1754
  generatedDrafts.push(...freshForTarget);
1725
1755
  warnings.push(...generated.warnings.map((w) => `generate: ${w}`));
1726
1756
  if (freshForTarget.length === 0)
1727
- break;
1757
+ continue;
1728
1758
  existingTitles.push(...freshForTarget.map((test) => test.title));
1729
1759
  remaining = Math.max(0, remaining - freshForTarget.length);
1730
1760
  }
1731
1761
  if (remaining > 0)
1732
1762
  incompleteTargets.push(target.id);
1763
+ flowDiagnostic.final_tests = target.existingTests.length + target.deficit - remaining;
1764
+ flowDiagnostic.remaining_shortfall = remaining;
1765
+ flowDiagnostics.push(flowDiagnostic);
1733
1766
  }
1734
1767
  const blockers = {};
1735
1768
  for (const draft of generatedDrafts.filter((test) => test.runnable === false)) {
@@ -1759,6 +1792,25 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1759
1792
  };
1760
1793
  if (generationResult.reason)
1761
1794
  warnings.push(`generate: ${generationResult.reason}`);
1795
+ const diagnostics = {
1796
+ schema_version: 1,
1797
+ started_at: diagnosticsStartedAt,
1798
+ completed_at: deps.clock(),
1799
+ expected_flows: rankedGenerationGaps.length,
1800
+ expected_tests_per_flow: 2,
1801
+ persisted_test_intents_this_run: generatedDrafts.length,
1802
+ model_planned_tests_this_run: generatedDrafts.filter((test) => !test.unresolved_reason?.includes("Manual fallback retained")).length,
1803
+ manual_fallbacks_this_run: generatedDrafts.filter((test) => test.unresolved_reason?.includes("Manual fallback retained")).length,
1804
+ flows: flowDiagnostics
1805
+ };
1806
+ generationDiagnosticsPath = join(root, WORKSPACE_DIR, "generation-diagnostics.json");
1807
+ try {
1808
+ writeFileAtomic(generationDiagnosticsPath, JSON.stringify(diagnostics, null, 2) + "\n");
1809
+ }
1810
+ catch (error) {
1811
+ generationDiagnosticsPath = undefined;
1812
+ warnings.push(`generation diagnostics not written: ${redactSecrets(error instanceof Error ? error.message : String(error))}`);
1813
+ }
1762
1814
  }
1763
1815
  }
1764
1816
  catch (err) {
@@ -1917,6 +1969,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1917
1969
  ai_linked: aiLinked,
1918
1970
  behavior_coverage_path: coverageHtml,
1919
1971
  coverage_report_path: coverageReport,
1972
+ generation_diagnostics_path: generationDiagnosticsPath,
1920
1973
  rtm,
1921
1974
  changed,
1922
1975
  gaps,
@@ -1984,6 +2037,7 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1984
2037
  input_mode: opts.input_mode,
1985
2038
  prompt_version: opts.prompt_version,
1986
2039
  existing_generated_test_titles: opts.existing_generated_test_titles,
2040
+ manual_planning_fallback: opts.manual_planning_fallback,
1987
2041
  // Persisting lane: a runnable draft for an unchanged target is reused as-is
1988
2042
  // rather than re-bought from the model on every run.
1989
2043
  pin_unchanged: opts.pin_unchanged ?? true
@@ -1,3 +1,4 @@
1
+ import { loadRiskConfig, globToRegExp } from "./riskConfig.js";
1
2
  import { execFileSync } from "node:child_process";
2
3
  const ENTRY_PATH_RE = /(^|\/)(routes?|controllers?|handlers?|jobs?|workers?|processors?|queues?|consumers?|subscribers?|listeners?|server|cmd)\//i;
3
4
  const ENTRY_FILE_RE = /(^|\/)[^/]*(controller|handler|route|router|job|processor|worker|queue|consumer|subscriber|listener|command|gateway)\.[^.\/]+$/i;
@@ -311,17 +312,33 @@ const DETECTION_MAP = {
311
312
  };
312
313
  const NEW_CODE_DAYS = 30;
313
314
  const NEW_CODE_SECONDS = NEW_CODE_DAYS * 24 * 60 * 60;
314
- function computeRawORS(node, depthCtx, incomingRefs, gitChurn, fanOut, detectionTier, firstCommitTs, nowSec) {
315
+ /** Calls that make a defect irreversible: deletes, drops, purges. Matched on the
316
+ * LAST segment of a call name, so `t.adminClient.DeleteWorkflowExecution` and a
317
+ * local `purgeAll` both count; `deleteButtonLabel` (no call) does not. */
318
+ const DESTRUCTIVE_CALL_RE = /^(delete|drop|purge|truncate|remove|forcedelete|destroy)[A-Za-z0-9_]*$/i;
319
+ const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(Run|Execute|Handle|Process|Tick|Scan)$/;
320
+ const SCHEDULED_ENTRY_PATH_RE = /(^|\/)(jobs?|workers?|scanners?|scavengers?|cron|schedulers?|processors?|consumers?|reconcil\w*)(\/|$)/i;
321
+ /** A time- or queue-triggered entry: nothing calls it synchronously, nobody waits
322
+ * for a response, so a failure is far less likely to be NOTICED. Graph facts only. */
323
+ export function isScheduledEntry(node) {
324
+ return SCHEDULED_ENTRY_NAME_RE.test(node.title || "") && SCHEDULED_ENTRY_PATH_RE.test(symbolFile(node));
325
+ }
326
+ function computeRawORS(node, depthCtx, incomingRefs, gitChurn, fanOut, detectionTier, firstCommitTs, nowSec, signals = { reachesDestructiveSink: false, scheduledEntry: false }) {
315
327
  const isNew = firstCommitTs > 0 && nowSec - firstCommitTs < NEW_CODE_SECONDS;
316
328
  const complexity = complexityProxy(node);
317
329
  const rawP = gitChurn * 0.35 + fanOut * 0.3 + (isNew ? 15 : 0) + complexity * 0.2;
318
330
  const routeWeight = deriveRouteWeight(node);
319
331
  const flowDepth = getFlowDepth(node, depthCtx);
320
332
  const flowPosition = Math.max(0, 5 - flowDepth);
321
- const dataSensitivity = deriveDataSensitivity(node);
333
+ const dataSensitivity = signals.sensitivityIgnored ? 0 : deriveDataSensitivity(node);
334
+ // Irreversibility is impact: a bug on a path that reaches a delete/purge cannot be
335
+ // rolled back. Bounded, additive, graph-derived (Fix C, signal 1).
322
336
  const rawI = incomingRefs * 0.3 + routeWeight * 0.3 + flowPosition * 0.2 + dataSensitivity * 0.2;
323
- const d = DETECTION_MAP[detectionTier];
324
- return { p: rawP, i: rawI, d };
337
+ // Silence lowers detectability: an unproven behavior that runs on a timer or a
338
+ // queue fails where no request surfaces it (Fix C, signal 2). Proven stays proven.
339
+ const silentFactor = signals.scheduledEntry && detectionTier !== "associated" ? 1.25 : 1;
340
+ const d = DETECTION_MAP[detectionTier] * silentFactor;
341
+ return { p: rawP, i: rawI, d, reachesDestructiveSink: signals.reachesDestructiveSink, scheduledEntry: signals.scheduledEntry, sensitivityIgnored: signals.sensitivityIgnored };
325
342
  }
326
343
  function staticTestLinkedIds(graph, candidateIds) {
327
344
  const ids = new Set();
@@ -358,7 +375,31 @@ function candidateSignalIds(graph, candidateIds) {
358
375
  export function rankRiskGaps(graph, opts = {}) {
359
376
  const limit = opts.limit ?? 20;
360
377
  const confirmed = confirmedBehaviorIds(graph, opts.provenIds);
361
- const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && !confirmed.has(n.external_id));
378
+ // Fix D: ranking hygiene. Test-support code, bare constants/variables, and trivial
379
+ // accessors stay in the behavior DENOMINATOR but never compete for a risk slot.
380
+ const TEST_SUPPORT_PATH_RE = /(^|\/)(testing|testutils?|testhelpers?|fixtures?|mocks?|fakes?)(\/|$)/i;
381
+ const extraTestSupportRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.classification.test_support_paths.map(globToRegExp);
382
+ const ACCESSOR_RE = /(^|\.)(Get|Set|Is|Has)[A-Z][A-Za-z0-9]*$/;
383
+ const rankEligible = (n) => {
384
+ const props = (n.properties ?? {});
385
+ if (TEST_SUPPORT_PATH_RE.test(symbolFile(n)))
386
+ return false;
387
+ if (extraTestSupportRef.some((re) => re.test(symbolFile(n))))
388
+ return false;
389
+ if (props.symbol_kind === "constant" || props.symbol_kind === "variable")
390
+ return false;
391
+ // Go `var x = ...` / `const` / `type` one-liners are minted as "class" with a
392
+ // 0–2 line span and no body to test: declarations, not behaviors.
393
+ if (props.symbol_kind === "class" && ((props.end_line ?? 0) - (props.start_line ?? 0)) <= 2)
394
+ return false;
395
+ const span = (props.end_line ?? 0) - (props.start_line ?? 0);
396
+ if (ACCESSOR_RE.test(n.title || "") && span <= 3)
397
+ return false;
398
+ return true;
399
+ };
400
+ const suppressedRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.overrides.filter((o) => o.action === "suppress");
401
+ const isSuppressed = (id) => suppressedRef.some((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
402
+ const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && !confirmed.has(n.external_id) && rankEligible(n) && !isSuppressed(n.external_id));
362
403
  const symbolIds = new Set(symbols.map((s) => s.external_id));
363
404
  const symbolsByFile = new Map();
364
405
  for (const s of symbols) {
@@ -371,6 +412,13 @@ export function rankRiskGaps(graph, opts = {}) {
371
412
  }
372
413
  const files = [...new Set(symbols.map(symbolFile))];
373
414
  const repoRoot = opts.repoRoot ?? graph.workspace.root;
415
+ const loadedCfg = loadRiskConfig(repoRoot);
416
+ const cfg = loadedCfg.config;
417
+ const overrideFor = (id) => cfg.overrides.find((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
418
+ const extraTestSupport = cfg.classification.test_support_paths.map(globToRegExp);
419
+ const extraScheduled = cfg.classification.scheduled_entry_paths.map(globToRegExp);
420
+ const extraSinks = cfg.classification.destructive_sinks.map(globToRegExp);
421
+ const sensitivityIgnore = cfg.classification.sensitivity_ignore.map(globToRegExp);
374
422
  const inputHealth = inspectRiskInputHealth(repoRoot, opts.churnWindow);
375
423
  const churnWindow = inputHealth.churnWindow;
376
424
  const churnResult = inputHealth.churnAvailable ? gitChurn(repoRoot, files, churnWindow) : { values: new Map(), complete: false };
@@ -428,6 +476,36 @@ export function rankRiskGaps(graph, opts = {}) {
428
476
  fanOutTargets.set(e.from_external_id, set);
429
477
  }
430
478
  const fanOut = new Map(symbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
479
+ // Fix C signals: destructive reach via CALLS (<=3 hops) to a sink; sinks are symbols
480
+ // whose own name, or a retained external callee, is a destructive call.
481
+ const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
482
+ const lastSeg = (name) => name.split(".").pop() ?? name;
483
+ // A sink is a destructive call on an EXTERNAL surface — a persistence store, an
484
+ // admin/service client, a database handle. In-repo methods named `delete` are
485
+ // not sinks by name alone (CHASM's `Node.delete` is a tree op, not a data loss).
486
+ const SINK_QUALIFIER_RE = /(client|store|manager|persistence|db|admin|repo|repository|dao|storage|bucket|index)/i;
487
+ const isSink = (id) => {
488
+ const n = nodeById.get(id);
489
+ if (!n)
490
+ return false;
491
+ const ext = n.properties?.external_callees ?? [];
492
+ return ext.some((c) => (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) && SINK_QUALIFIER_RE.test(c.slice(0, c.lastIndexOf(".")))) || extraSinks.some((re) => re.test(lastSeg(c))));
493
+ };
494
+ const reachesSinkFrom = (id, depth, seen) => {
495
+ if (isSink(id))
496
+ return true;
497
+ if (depth === 0)
498
+ return false;
499
+ for (const t of fanOutTargets.get(id) ?? []) {
500
+ if (seen.has(t))
501
+ continue;
502
+ seen.add(t);
503
+ if (reachesSinkFrom(t, depth - 1, seen))
504
+ return true;
505
+ }
506
+ return false;
507
+ };
508
+ const reachesSink = new Map(symbols.map((s) => [s.external_id, reachesSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
431
509
  const depthCtx = buildFlowDepthContext(graph);
432
510
  const staticLinked = staticTestLinkedIds(graph, symbolIds);
433
511
  const candidateLinked = candidateSignalIds(graph, symbolIds);
@@ -460,10 +538,19 @@ export function rankRiskGaps(graph, opts = {}) {
460
538
  const git_churn = symbolChurn(s);
461
539
  const fan_out = fanOut.get(s.external_id) ?? 0;
462
540
  const ts = firstCommitTs.get(file) ?? 0;
463
- return computeRawORS(s, depthCtx, incoming_refs, git_churn, fan_out, detectionFor(s.external_id), ts, nowSec);
541
+ return computeRawORS(s, depthCtx, incoming_refs, git_churn, fan_out, detectionFor(s.external_id), ts, nowSec, {
542
+ reachesDestructiveSink: cfg.tuning.irreversibility_floor && (reachesSink.get(s.external_id) ?? false),
543
+ scheduledEntry: cfg.tuning.silence_multiplier && (isScheduledEntry(s) || (SCHEDULED_ENTRY_NAME_RE.test(s.title || "") && extraScheduled.some((re) => re.test(symbolFile(s))))),
544
+ sensitivityIgnored: sensitivityIgnore.some((re) => re.test(s.title || "") || re.test(s.external_id)) || overrideFor(s.external_id)?.action === "reclassify" && overrideFor(s.external_id)?.sensitivity === "none"
545
+ });
464
546
  });
547
+ const pinnedIds = new Set(symbols.filter((s) => overrideFor(s.external_id)?.action === "pin").map((s) => s.external_id));
465
548
  const pScores = normalizeScores(rawScores.map((r) => r.p));
466
- const iScores = normalizeScores(rawScores.map((r) => r.i));
549
+ const iScoresRaw = normalizeScores(rawScores.map((r) => r.i));
550
+ // Fix C: a path that can reach a delete/purge has irreversible consequences no
551
+ // matter how few callers it has. Impact FLOORS at 5/10 for such paths (a floor,
552
+ // not an increment — an additive bump vanishes under hub-dominated normalization).
553
+ const iScores = iScoresRaw.map((v, idx) => (rawScores[idx].reachesDestructiveSink ? Math.max(v, 5) : v));
467
554
  const ranked = symbols
468
555
  .map((s, idx) => {
469
556
  const file = symbolFile(s);
@@ -472,7 +559,7 @@ export function rankRiskGaps(graph, opts = {}) {
472
559
  const fan_out = fanOut.get(s.external_id) ?? 0;
473
560
  const isEntry = entryPoint.get(s.external_id) ?? false;
474
561
  const route_weight = deriveRouteWeight(s);
475
- const data_sensitivity = deriveDataSensitivity(s);
562
+ const data_sensitivity = rawScores[idx].sensitivityIgnored ? 0 : deriveDataSensitivity(s);
476
563
  const flow_position = Math.max(0, 5 - getFlowDepth(s, depthCtx));
477
564
  const complexity_proxy = complexityProxy(s);
478
565
  const firstTs = firstCommitTs.get(file) ?? 0;
@@ -507,7 +594,17 @@ export function rankRiskGaps(graph, opts = {}) {
507
594
  reasons.push("no callers and no callees — structurally disconnected, score dampened");
508
595
  if (detectionTier === "candidate")
509
596
  reasons.push("lexical candidate test match only — unconfirmed");
597
+ // Reason chips for the two consequence signals — a reader can disagree with the
598
+ // weight without doubting the fact, and the fact is what's shown.
599
+ if (rawScores[idx].reachesDestructiveSink)
600
+ reasons.push("reaches a destructive external call (delete/purge) within 2 hops — impact floored at 5");
601
+ if (rawScores[idx].scheduledEntry)
602
+ reasons.push("scheduled/queue-triggered entry with no proof — failures surface nowhere, detection ×1.25");
603
+ const override = overrideFor(s.external_id);
604
+ if (override && override.action !== "suppress")
605
+ reasons.push(`config override (${override.action}): ${override.reason}`);
510
606
  return {
607
+ ...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
511
608
  id: s.external_id,
512
609
  title: s.title || s.external_id,
513
610
  file,
@@ -533,8 +630,15 @@ export function rankRiskGaps(graph, opts = {}) {
533
630
  // The default API is the true global ranking because reports call this list
534
631
  // "top risks". Callers may explicitly request a diversified portfolio, but
535
632
  // that presentation policy must never silently redefine rank.
633
+ const withPins = (list) => {
634
+ if (pinnedIds.size === 0)
635
+ return list;
636
+ const have = new Set(list.map((r) => r.id));
637
+ const extra = ranked.filter((r) => pinnedIds.has(r.id) && !have.has(r.id));
638
+ return extra.length ? [...list, ...extra] : list;
639
+ };
536
640
  if (opts.maxPerFile === undefined)
537
- return ranked.slice(0, limit);
641
+ return withPins(ranked.slice(0, limit));
538
642
  const maxPerFile = Math.max(1, opts.maxPerFile);
539
643
  const perFile = new Map();
540
644
  // Multi-program repos flood identical titles (76 x main) across files; the
@@ -576,9 +680,9 @@ export function rankRiskGaps(graph, opts = {}) {
576
680
  // Guarantee: the surfaced list is ALWAYS highest-risk-first, even when the
577
681
  // per-file diversity backfill re-admits overflow items (which otherwise land
578
682
  // appended after lower-scored rows).
579
- return surfaced
683
+ return withPins(surfaced
580
684
  .sort((a, b) => b.risk_score - a.risk_score || b.incoming_refs - a.incoming_refs || b.git_churn - a.git_churn || a.id.localeCompare(b.id))
581
- .slice(0, limit);
685
+ .slice(0, limit));
582
686
  }
583
687
  /**
584
688
  * Canonical priority-gap portfolio shown to a local user and used for automatic
@@ -0,0 +1,62 @@
1
+ // Per-repo risk configuration — the ONLY levers a repo can pull on scoring.
2
+ //
3
+ // Design rule (see docs): classification and a handful of tuning switches are
4
+ // configurable; evidence tiers, the proof oracle, the formula shape, and raw
5
+ // P/I/D weights are NOT. Every override requires a reason and is surfaced in
6
+ // the report, and the config hash is part of the determinism claim:
7
+ // same commit + same version + same config ⇒ same ranking.
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ export const DEFAULT_RISK_CONFIG = {
12
+ classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [] },
13
+ tuning: { irreversibility_floor: true, silence_multiplier: true },
14
+ overrides: []
15
+ };
16
+ function asStringArray(v) {
17
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
18
+ }
19
+ /** Glob → RegExp: `*` matches within a path segment, `**` matches across segments. */
20
+ export function globToRegExp(glob) {
21
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
22
+ return new RegExp(`^${esc}$`);
23
+ }
24
+ export function loadRiskConfig(repoRoot) {
25
+ const warnings = [];
26
+ const cfg = JSON.parse(JSON.stringify(DEFAULT_RISK_CONFIG));
27
+ const file = join(repoRoot, ".orangepro", "config.json");
28
+ if (repoRoot && existsSync(file)) {
29
+ try {
30
+ const raw = JSON.parse(readFileSync(file, "utf8"));
31
+ const cls = (raw.classification ?? {});
32
+ cfg.classification.test_support_paths = asStringArray(cls.test_support_paths);
33
+ cfg.classification.scheduled_entry_paths = asStringArray(cls.scheduled_entry_paths);
34
+ cfg.classification.destructive_sinks = asStringArray(cls.destructive_sinks);
35
+ cfg.classification.sensitivity_ignore = asStringArray(cls.sensitivity_ignore);
36
+ const tun = (raw.tuning ?? {});
37
+ if (typeof tun.irreversibility_floor === "boolean")
38
+ cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
39
+ if (typeof tun.silence_multiplier === "boolean")
40
+ cfg.tuning.silence_multiplier = tun.silence_multiplier;
41
+ for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
42
+ const ov = o;
43
+ if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
44
+ warnings.push(`config: override ignored (needs symbol + action): ${JSON.stringify(o).slice(0, 80)}`);
45
+ continue;
46
+ }
47
+ if (typeof ov.reason !== "string" || ov.reason.trim().length < 8) {
48
+ warnings.push(`config: override for ${ov.symbol} ignored — a reason (≥8 chars) is required so it can be shown on the report.`);
49
+ continue;
50
+ }
51
+ cfg.overrides.push({ symbol: ov.symbol, action: ov.action, sensitivity: ov.sensitivity, reason: ov.reason.trim() });
52
+ }
53
+ }
54
+ catch (err) {
55
+ warnings.push(`config: .orangepro/config.json unreadable for risk settings (${err.message}); defaults used.`);
56
+ }
57
+ }
58
+ const canonical = JSON.stringify(cfg, Object.keys(cfg).sort());
59
+ const hash = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 12);
60
+ void canonical;
61
+ return { config: cfg, hash, warnings };
62
+ }
@@ -1,3 +1,4 @@
1
+ import { loadRiskConfig } from "../score/riskConfig.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import path from "node:path";
3
4
  import { buildRtm } from "../rtm.js";
@@ -457,8 +458,12 @@ function riskContext(risk) {
457
458
  const churn = risk.churn_available !== false
458
459
  ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days`
459
460
  : "Git churn unavailable (provisional static-only ranking)";
461
+ // Consequence signals and config overrides are stated on the row, so a reader can
462
+ // disagree with a weight without doubting the fact — and a tuned report is visible.
463
+ const flagged = (risk.reasons ?? []).filter((r) => r.startsWith("reaches a destructive") || r.startsWith("scheduled/queue-triggered") || r.startsWith("config override"));
460
464
  const parts = [
461
465
  `Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
466
+ ...flagged.map((r) => `${r[0].toUpperCase()}${r.slice(1)}.`),
462
467
  `ORS ${risk.risk_score} (P${risk.probability ?? "?"} × I${risk.impact ?? "?"} × D${risk.detection_difficulty ?? "?"}) reflects flow position, change activity, complexity, impact, and test evidence; ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${churn} — and no test proves this flow.`
463
468
  ];
464
469
  return parts.join(" ");
@@ -483,13 +488,14 @@ export function computeReportDelta(prev, cur) {
483
488
  droppedRisks,
484
489
  generatedDelta: cur.generatedTotal - prev.generatedTotal
485
490
  };
491
+ d.configChanged = Boolean(prev.configHash && cur.provenance?.configHash && prev.configHash !== cur.provenance.configHash);
486
492
  d.changed =
487
493
  d.totalDelta !== 0 || d.provenDelta !== 0 || d.associatedDelta !== 0 || d.candidateDelta !== 0 ||
488
- d.noneDelta !== 0 || d.generatedDelta !== 0 || newRisks.length > 0 || droppedRisks.length > 0;
494
+ d.noneDelta !== 0 || d.generatedDelta !== 0 || newRisks.length > 0 || droppedRisks.length > 0 || d.configChanged;
489
495
  return d;
490
496
  }
491
497
  export function reportBaselineOf(cur, ts) {
492
- return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal };
498
+ return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal, configHash: cur.provenance?.configHash };
493
499
  }
494
500
  const HTTP_TRIGGER_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"]);
495
501
  const GRAPHQL_TRIGGER_VERBS = new Set(["MUTATION", "QUERY", "SUBSCRIPTION"]);
@@ -902,6 +908,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
902
908
  churn: churnAvailable ? "available" : "unavailable",
903
909
  churnWindow: riskHealth.churnWindow,
904
910
  toolVersion: ORANGEPRO_VERSION,
911
+ configHash: loadRiskConfig(repoRoot).hash,
905
912
  inputFingerprint: createHash("sha256")
906
913
  .update(JSON.stringify({ root: graph.workspace.root_hash, commit: riskHealth.commit, history: riskHealth.history, churn: churnAvailable, window: riskHealth.churnWindow, version: ORANGEPRO_VERSION }))
907
914
  .digest("hex")
@@ -124,6 +124,15 @@ generated test is still marked Proven only when it genuinely kills the null-sent
124
124
  via the **unchanged** dynamic-proof oracle. The default path is unchanged (no key required
125
125
  for v2/deterministic; existing behavior and CI are untouched).
126
126
 
127
+ For v5 generation started through `opro start`, each priority flow gets two bounded
128
+ planning attempts. If the configured model still returns no accepted distinct scenario,
129
+ the report retains deterministic contract and failure-path **manual intents** for the
130
+ missing slots. These are explicitly non-runnable, are never presented as model-generated
131
+ code or proof, and include the redacted planning failure that caused the fallback.
132
+ `.orangepro/generation-diagnostics.json` records each flow's requested slots, attempts,
133
+ accepted model plans, manual fallbacks, and redacted rejection reasons. It never stores
134
+ raw prompts, raw model responses, source code, or provider keys.
135
+
127
136
  Exercise the live v5 loop against a tiny fixture with a keyed smoke:
128
137
 
129
138
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.34",
3
+ "version": "0.2.35",
4
4
  "private": false,
5
5
  "description": "OrangePro (`opro`) — a local-first, BYOK CLI + MCP server that builds an evidence graph from a local checkout, ingests runtime coverage, and generates grounded tests. Metadata-only exports; no source upload; generated tests stay local.",
6
6
  "license": "MIT",