@orangepro/orangepro-mcp 0.2.34 → 0.2.36

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
 
@@ -475,6 +475,18 @@ export function analyzeRepo(root, opts = {}) {
475
475
  // Emitted CodeSymbol names per file — the call graph resolves callers/callees
476
476
  // ONLY to symbols that actually became nodes (the "known symbol" invariant).
477
477
  const symbolsByFile = new Map();
478
+ // Retained callee NAMES (no node, no edge) for calls the resolver cannot anchor —
479
+ // shared by the TS/JS loop and the tree-sitter loop; persisted as external_callees.
480
+ const externalCalleesByCaller = new Map();
481
+ const recordExternalCallee = (callerId, name) => {
482
+ let set = externalCalleesByCaller.get(callerId);
483
+ if (!set) {
484
+ set = new Set();
485
+ externalCalleesByCaller.set(callerId, set);
486
+ }
487
+ if (set.size < 32)
488
+ set.add(name);
489
+ };
478
490
  // Raw (caller, callee) call pairs per TS/JS code file, resolved after the
479
491
  // import graph is built (cross-file calls need its bindings + targets).
480
492
  const rawCallsByFile = new Map();
@@ -1312,9 +1324,19 @@ export function analyzeRepo(root, opts = {}) {
1312
1324
  }
1313
1325
  }
1314
1326
  // A non-imported (local/param) qualifier is NOT anchored — no edge.
1327
+ if (!ns && !qImport)
1328
+ recordExternalCallee(callerId, `${c.qualifier}.${c.callee}`); // round two: retain by name
1329
+ // Retain the callee NAME as a fact on the caller (Fix B): `t.adminClient.
1330
+ // DeleteWorkflowExecution` is invisible as an edge (external interface) but
1331
+ // is exactly the kind of sink risk scoring must be able to see. Names only —
1332
+ // no node, no edge, no evidence claim.
1315
1333
  }
1316
1334
  }
1317
1335
  else if (c.via === "injected" && c.injectedType) {
1336
+ // Retain `this.<field>.<callee>` by name regardless of resolution: names are
1337
+ // facts, not evidence, and consequence signals need the callee even when the
1338
+ // injected type never resolves (round two — TS parity with Go).
1339
+ recordExternalCallee(callerId, `this.${c.qualifier ?? c.injectedType}.${c.callee}`);
1318
1340
  const typeBinding = typeImports?.get(c.injectedType) ?? imports?.get(c.injectedType);
1319
1341
  if (typeBinding) {
1320
1342
  const targetMember = resolveInjectedMember(typeBinding, c.callee);
@@ -1835,6 +1857,26 @@ export function analyzeRepo(root, opts = {}) {
1835
1857
  addImportBinding(rel, i.local, { ...(targetRel ? { targetRel } : {}), ...(targetDir ? { targetDir } : {}) }, i.imported, i.kind);
1836
1858
  }
1837
1859
  }
1860
+ // Go package-level method index (dir → "Recv.M" → file) for receiver calls that
1861
+ // land in a sibling file of the same package.
1862
+ const goPkgMethods = new Map();
1863
+ for (const [f, syms] of symbolsByFile) {
1864
+ if (!f.endsWith(".go") || f.endsWith("_test.go"))
1865
+ continue;
1866
+ const dir = f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "";
1867
+ let m = goPkgMethods.get(dir);
1868
+ if (!m) {
1869
+ m = new Map();
1870
+ goPkgMethods.set(dir, m);
1871
+ }
1872
+ for (const sname of syms)
1873
+ if (sname.includes("."))
1874
+ m.set(sname, f);
1875
+ }
1876
+ const goPackageMethodFile = (rel, member) => {
1877
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
1878
+ return goPkgMethods.get(dir)?.get(member);
1879
+ };
1838
1880
  for (const [rel, { language, structure }] of nonTsStructureByFile) {
1839
1881
  const localSyms = symbolsByFile.get(rel);
1840
1882
  if (!localSyms)
@@ -1869,7 +1911,28 @@ export function analyzeRepo(root, opts = {}) {
1869
1911
  if (shadowed.has(rootQualifier))
1870
1912
  continue;
1871
1913
  const b = imports?.get(rootQualifier);
1872
- if (language === "go" && b?.kind === "module" && b.targetDir) {
1914
+ if (language === "go" && c.receiverVar && c.receiverType && c.qualifier === c.receiverVar) {
1915
+ // Go receiver-method call: inside `func (t *task) Run()`, `t.validate()` calls
1916
+ // `task.validate`. The receiver's type is DECLARED in the method signature, so
1917
+ // this is exact resolution: same file first, then the same package (methods of
1918
+ // one type may span files; Go forbids duplicate method names on a type, so a
1919
+ // package-level hit is unambiguous).
1920
+ const member = `${c.receiverType}.${c.callee}`;
1921
+ if (localSyms.has(member))
1922
+ emitCall(callerId, `sym:${rel}#${member}`, rel);
1923
+ else {
1924
+ const pkgRel = goPackageMethodFile(rel, member);
1925
+ if (pkgRel)
1926
+ emitCall(callerId, `sym:${pkgRel}#${member}`, rel);
1927
+ }
1928
+ }
1929
+ else if (language === "go" && !b && c.receiverVar && rootQualifier === c.receiverVar) {
1930
+ // `t.client.Delete(...)`: a call through a receiver FIELD to an external or
1931
+ // interface method — no resolvable node. Retain the callee NAME on the caller
1932
+ // (Fix B) so scoring can see sinks like `DeleteWorkflowExecution`. Names only.
1933
+ recordExternalCallee(callerId, `${c.qualifier}.${c.callee}`);
1934
+ }
1935
+ else if (language === "go" && b?.kind === "module" && b.targetDir) {
1873
1936
  const targetRel = uniqueGoPackageSymbol(b.targetDir, c.callee);
1874
1937
  if (targetRel)
1875
1938
  emitCall(callerId, `sym:${targetRel}#${c.callee}`, rel);
@@ -1898,6 +1961,16 @@ export function analyzeRepo(root, opts = {}) {
1898
1961
  }
1899
1962
  }
1900
1963
  }
1964
+ // Fix B: persist retained callee names on their caller symbols (names only).
1965
+ if (externalCalleesByCaller.size > 0) {
1966
+ const byId = new Map(nodes.map((n) => [n.external_id, n]));
1967
+ for (const [callerId, names] of externalCalleesByCaller) {
1968
+ const n = byId.get(callerId);
1969
+ if (!n)
1970
+ continue;
1971
+ n.properties = { ...(n.properties ?? {}), external_callees: [...names].sort() };
1972
+ }
1973
+ }
1901
1974
  const seenGoProof = new Set();
1902
1975
  const proofVerifiedAt = scanStartMs;
1903
1976
  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,40 @@ 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
+ // `truncate` dropped (time.Truncate is common; DB truncation is rare in app code);
319
+ // `remove` kept but not for listener/handler/attribute/child tails — those detach,
320
+ // they don't destroy data.
321
+ const DESTRUCTIVE_CALL_RE = /^(?:delete(?!d)|purge|drop|forcedelete|destroy)[A-Za-z0-9_]*$/i;
322
+ // `remove*` is only a data sink through a persistence-shaped field; in-memory
323
+ // removals (`pollers.Remove`, `RemoveSpeculativeWorkflowTaskTimeout`) are not.
324
+ const REMOVE_CALL_RE = /^remove(?![A-Za-z0-9_]*(?:listener|handler|observer|callback|attribute|attr|class|child|style|hook|timeout|timer)$)[A-Za-z0-9_]*$/i;
325
+ const PERSISTENCE_FIELD_RE = /(store|client|db|repo|repository|persistence|manager|queue|bucket|index|table|storage|dao)/i;
326
+ const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(run|execute|handle|process|tick|scan)$/i;
327
+ const SCHEDULED_ENTRY_PATH_RE = /(^|\/)(jobs?|workers?|scanners?|scavengers?|cron|schedulers?|processors?|consumers?|reconcil\w*)(\/|$)/i;
328
+ /** A time- or queue-triggered entry: nothing calls it synchronously, nobody waits
329
+ * for a response, so a failure is far less likely to be NOTICED. Graph facts only. */
330
+ export function isScheduledEntry(node) {
331
+ return SCHEDULED_ENTRY_NAME_RE.test(node.title || "") && SCHEDULED_ENTRY_PATH_RE.test(symbolFile(node));
332
+ }
333
+ function computeRawORS(node, depthCtx, incomingRefs, gitChurn, fanOut, detectionTier, firstCommitTs, nowSec, signals = { reachesDestructiveSink: false, scheduledEntry: false }) {
315
334
  const isNew = firstCommitTs > 0 && nowSec - firstCommitTs < NEW_CODE_SECONDS;
316
335
  const complexity = complexityProxy(node);
317
336
  const rawP = gitChurn * 0.35 + fanOut * 0.3 + (isNew ? 15 : 0) + complexity * 0.2;
318
337
  const routeWeight = deriveRouteWeight(node);
319
338
  const flowDepth = getFlowDepth(node, depthCtx);
320
339
  const flowPosition = Math.max(0, 5 - flowDepth);
321
- const dataSensitivity = deriveDataSensitivity(node);
340
+ const dataSensitivity = signals.sensitivityIgnored ? 0 : deriveDataSensitivity(node);
341
+ // Irreversibility is impact: a bug on a path that reaches a delete/purge cannot be
342
+ // rolled back. Bounded, additive, graph-derived (Fix C, signal 1).
322
343
  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 };
344
+ // Silence lowers detectability: an unproven behavior that runs on a timer or a
345
+ // queue fails where no request surfaces it (Fix C, signal 2). Proven stays proven.
346
+ const silentFactor = signals.scheduledEntry && detectionTier !== "associated" ? 1.25 : 1;
347
+ const d = DETECTION_MAP[detectionTier] * silentFactor;
348
+ return { p: rawP, i: rawI, d, reachesDestructiveSink: signals.reachesDestructiveSink, scheduledEntry: signals.scheduledEntry, sensitivityIgnored: signals.sensitivityIgnored };
325
349
  }
326
350
  function staticTestLinkedIds(graph, candidateIds) {
327
351
  const ids = new Set();
@@ -358,7 +382,35 @@ function candidateSignalIds(graph, candidateIds) {
358
382
  export function rankRiskGaps(graph, opts = {}) {
359
383
  const limit = opts.limit ?? 20;
360
384
  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));
385
+ // Fix D: ranking hygiene. Test-support code, bare constants/variables, and trivial
386
+ // accessors stay in the behavior DENOMINATOR but never compete for a risk slot.
387
+ const TEST_SUPPORT_PATH_RE = /(^|\/)(testing|testutils?|testhelpers?|fixtures?|mocks?|fakes?)(\/|$)/i;
388
+ const cfgEarly = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.classification;
389
+ const extraTestSupportRef = cfgEarly.test_support_paths.map(globToRegExp);
390
+ const rankExcludeRef = cfgEarly.rank_exclude_paths.map(globToRegExp);
391
+ const ACCESSOR_RE = /(^|\.)(Get|Set|Is|Has)[A-Z][A-Za-z0-9]*$/;
392
+ const rankEligible = (n) => {
393
+ const props = (n.properties ?? {});
394
+ if (TEST_SUPPORT_PATH_RE.test(symbolFile(n)))
395
+ return false;
396
+ if (extraTestSupportRef.some((re) => re.test(symbolFile(n))))
397
+ return false;
398
+ if (props.symbol_kind === "constant" || props.symbol_kind === "variable")
399
+ return false;
400
+ // Go `var x = ...` / `const` / `type` one-liners are minted as "class" with a
401
+ // 0–2 line span and no body to test: declarations, not behaviors.
402
+ if (props.symbol_kind === "class" && ((props.end_line ?? 0) - (props.start_line ?? 0)) <= 2)
403
+ return false;
404
+ const span = (props.end_line ?? 0) - (props.start_line ?? 0);
405
+ if (ACCESSOR_RE.test(n.title || "") && span <= 3)
406
+ return false;
407
+ if (rankExcludeRef.some((re) => re.test(symbolFile(n))))
408
+ return false;
409
+ return true;
410
+ };
411
+ const suppressedRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.overrides.filter((o) => o.action === "suppress");
412
+ const isSuppressed = (id) => suppressedRef.some((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
413
+ 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
414
  const symbolIds = new Set(symbols.map((s) => s.external_id));
363
415
  const symbolsByFile = new Map();
364
416
  for (const s of symbols) {
@@ -371,6 +423,13 @@ export function rankRiskGaps(graph, opts = {}) {
371
423
  }
372
424
  const files = [...new Set(symbols.map(symbolFile))];
373
425
  const repoRoot = opts.repoRoot ?? graph.workspace.root;
426
+ const loadedCfg = loadRiskConfig(repoRoot);
427
+ const cfg = loadedCfg.config;
428
+ const overrideFor = (id) => cfg.overrides.find((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
429
+ const extraTestSupport = cfg.classification.test_support_paths.map(globToRegExp);
430
+ const extraScheduled = cfg.classification.scheduled_entry_paths.map(globToRegExp);
431
+ const extraSinks = cfg.classification.destructive_sinks.map(globToRegExp);
432
+ const sensitivityIgnore = cfg.classification.sensitivity_ignore.map(globToRegExp);
374
433
  const inputHealth = inspectRiskInputHealth(repoRoot, opts.churnWindow);
375
434
  const churnWindow = inputHealth.churnWindow;
376
435
  const churnResult = inputHealth.churnAvailable ? gitChurn(repoRoot, files, churnWindow) : { values: new Map(), complete: false };
@@ -428,6 +487,49 @@ export function rankRiskGaps(graph, opts = {}) {
428
487
  fanOutTargets.set(e.from_external_id, set);
429
488
  }
430
489
  const fanOut = new Map(symbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
490
+ // Fix C signals: destructive reach via CALLS (<=3 hops) to a sink; sinks are symbols
491
+ // whose own name, or a retained external callee, is a destructive call.
492
+ const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
493
+ const lastSeg = (name) => name.split(".").pop() ?? name;
494
+ // A sink is a destructive call on an EXTERNAL surface — a persistence store, an
495
+ // admin/service client, a database handle. In-repo methods named `delete` are
496
+ // not sinks by name alone (CHASM's `Node.delete` is a tree op, not a data loss).
497
+ // A retained callee is by construction a call through a receiver FIELD to code
498
+ // the graph could not resolve — i.e. an external surface. That structural fact is
499
+ // the test; the qualifier's NAME is not (round one's store/client/db vocabulary was
500
+ // Temporal-shaped and hid inngest's `w.q.DeleteOldQueueSnapshots`).
501
+ const sinkCallee = (id) => {
502
+ const n = nodeById.get(id);
503
+ if (!n)
504
+ return undefined;
505
+ const ext = n.properties?.external_callees ?? [];
506
+ // A receiver FIELD path is `x.field.Method` — no call parentheses before the last
507
+ // segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
508
+ const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
509
+ const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
510
+ return ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
511
+ (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
512
+ extraSinks.some((re) => re.test(lastSeg(c)))));
513
+ };
514
+ const isSink = (id) => sinkCallee(id) !== undefined;
515
+ const reachedSinkFrom = (id, depth, seen) => {
516
+ const own = sinkCallee(id);
517
+ if (own)
518
+ return own;
519
+ if (depth === 0)
520
+ return undefined;
521
+ for (const t of fanOutTargets.get(id) ?? []) {
522
+ if (seen.has(t))
523
+ continue;
524
+ seen.add(t);
525
+ const hit = reachedSinkFrom(t, depth - 1, seen);
526
+ if (hit)
527
+ return hit;
528
+ }
529
+ return undefined;
530
+ };
531
+ const sinkReached = new Map(symbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
532
+ const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
431
533
  const depthCtx = buildFlowDepthContext(graph);
432
534
  const staticLinked = staticTestLinkedIds(graph, symbolIds);
433
535
  const candidateLinked = candidateSignalIds(graph, symbolIds);
@@ -460,10 +562,19 @@ export function rankRiskGaps(graph, opts = {}) {
460
562
  const git_churn = symbolChurn(s);
461
563
  const fan_out = fanOut.get(s.external_id) ?? 0;
462
564
  const ts = firstCommitTs.get(file) ?? 0;
463
- return computeRawORS(s, depthCtx, incoming_refs, git_churn, fan_out, detectionFor(s.external_id), ts, nowSec);
565
+ return computeRawORS(s, depthCtx, incoming_refs, git_churn, fan_out, detectionFor(s.external_id), ts, nowSec, {
566
+ reachesDestructiveSink: cfg.tuning.irreversibility_floor && (reachesSink.get(s.external_id) ?? false),
567
+ scheduledEntry: cfg.tuning.silence_multiplier && (isScheduledEntry(s) || (SCHEDULED_ENTRY_NAME_RE.test(s.title || "") && extraScheduled.some((re) => re.test(symbolFile(s))))),
568
+ 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"
569
+ });
464
570
  });
571
+ const pinnedIds = new Set(symbols.filter((s) => overrideFor(s.external_id)?.action === "pin").map((s) => s.external_id));
465
572
  const pScores = normalizeScores(rawScores.map((r) => r.p));
466
- const iScores = normalizeScores(rawScores.map((r) => r.i));
573
+ const iScoresRaw = normalizeScores(rawScores.map((r) => r.i));
574
+ // Fix C: a path that can reach a delete/purge has irreversible consequences no
575
+ // matter how few callers it has. Impact FLOORS at 5/10 for such paths (a floor,
576
+ // not an increment — an additive bump vanishes under hub-dominated normalization).
577
+ const iScores = iScoresRaw.map((v, idx) => (rawScores[idx].reachesDestructiveSink ? Math.max(v, 5) : v));
467
578
  const ranked = symbols
468
579
  .map((s, idx) => {
469
580
  const file = symbolFile(s);
@@ -472,7 +583,7 @@ export function rankRiskGaps(graph, opts = {}) {
472
583
  const fan_out = fanOut.get(s.external_id) ?? 0;
473
584
  const isEntry = entryPoint.get(s.external_id) ?? false;
474
585
  const route_weight = deriveRouteWeight(s);
475
- const data_sensitivity = deriveDataSensitivity(s);
586
+ const data_sensitivity = rawScores[idx].sensitivityIgnored ? 0 : deriveDataSensitivity(s);
476
587
  const flow_position = Math.max(0, 5 - getFlowDepth(s, depthCtx));
477
588
  const complexity_proxy = complexityProxy(s);
478
589
  const firstTs = firstCommitTs.get(file) ?? 0;
@@ -507,7 +618,20 @@ export function rankRiskGaps(graph, opts = {}) {
507
618
  reasons.push("no callers and no callees — structurally disconnected, score dampened");
508
619
  if (detectionTier === "candidate")
509
620
  reasons.push("lexical candidate test match only — unconfirmed");
621
+ // Reason chips for the two consequence signals — a reader can disagree with the
622
+ // weight without doubting the fact, and the fact is what's shown.
623
+ if (rawScores[idx].reachesDestructiveSink)
624
+ reasons.push("reaches a destructive external call (delete/purge) within 2 hops — impact floored at 5");
625
+ if (rawScores[idx].scheduledEntry)
626
+ reasons.push("scheduled/queue-triggered entry with no proof — failures surface nowhere, detection ×1.25");
627
+ const override = overrideFor(s.external_id);
628
+ if (override && override.action !== "suppress")
629
+ reasons.push(`config override (${override.action}): ${override.reason}`);
510
630
  return {
631
+ ...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
632
+ ...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
633
+ ...(rawScores[idx].scheduledEntry ? { scheduled_entry: true } : {}),
634
+ detection_tier: detectionTier,
511
635
  id: s.external_id,
512
636
  title: s.title || s.external_id,
513
637
  file,
@@ -533,8 +657,15 @@ export function rankRiskGaps(graph, opts = {}) {
533
657
  // The default API is the true global ranking because reports call this list
534
658
  // "top risks". Callers may explicitly request a diversified portfolio, but
535
659
  // that presentation policy must never silently redefine rank.
660
+ const withPins = (list) => {
661
+ if (pinnedIds.size === 0)
662
+ return list;
663
+ const have = new Set(list.map((r) => r.id));
664
+ const extra = ranked.filter((r) => pinnedIds.has(r.id) && !have.has(r.id));
665
+ return extra.length ? [...list, ...extra] : list;
666
+ };
536
667
  if (opts.maxPerFile === undefined)
537
- return ranked.slice(0, limit);
668
+ return withPins(ranked.slice(0, limit));
538
669
  const maxPerFile = Math.max(1, opts.maxPerFile);
539
670
  const perFile = new Map();
540
671
  // Multi-program repos flood identical titles (76 x main) across files; the
@@ -576,9 +707,9 @@ export function rankRiskGaps(graph, opts = {}) {
576
707
  // Guarantee: the surfaced list is ALWAYS highest-risk-first, even when the
577
708
  // per-file diversity backfill re-admits overflow items (which otherwise land
578
709
  // appended after lower-scored rows).
579
- return surfaced
710
+ return withPins(surfaced
580
711
  .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);
712
+ .slice(0, limit));
582
713
  }
583
714
  /**
584
715
  * Canonical priority-gap portfolio shown to a local user and used for automatic
@@ -0,0 +1,63 @@
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: [], rank_exclude_paths: [] },
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
+ cfg.classification.rank_exclude_paths = asStringArray(cls.rank_exclude_paths);
37
+ const tun = (raw.tuning ?? {});
38
+ if (typeof tun.irreversibility_floor === "boolean")
39
+ cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
40
+ if (typeof tun.silence_multiplier === "boolean")
41
+ cfg.tuning.silence_multiplier = tun.silence_multiplier;
42
+ for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
43
+ const ov = o;
44
+ if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
45
+ warnings.push(`config: override ignored (needs symbol + action): ${JSON.stringify(o).slice(0, 80)}`);
46
+ continue;
47
+ }
48
+ if (typeof ov.reason !== "string" || ov.reason.trim().length < 8) {
49
+ warnings.push(`config: override for ${ov.symbol} ignored — a reason (≥8 chars) is required so it can be shown on the report.`);
50
+ continue;
51
+ }
52
+ cfg.overrides.push({ symbol: ov.symbol, action: ov.action, sensitivity: ov.sensitivity, reason: ov.reason.trim() });
53
+ }
54
+ }
55
+ catch (err) {
56
+ warnings.push(`config: .orangepro/config.json unreadable for risk settings (${err.message}); defaults used.`);
57
+ }
58
+ }
59
+ const canonical = JSON.stringify(cfg, Object.keys(cfg).sort());
60
+ const hash = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 12);
61
+ void canonical;
62
+ return { config: cfg, hash, warnings };
63
+ }
@@ -1,7 +1,8 @@
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";
4
- import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps } from "../score/risk.js";
5
+ import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps } from "../score/risk.js";
5
6
  import { ORANGEPRO_VERSION } from "../version.js";
6
7
  import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
7
8
  import { classifyGeneratedDraftBlocker } from "../generate/draftGuidance.js";
@@ -450,17 +451,49 @@ function riskContext(risk) {
450
451
  : (risk.data_sensitivity ?? 1) >= 3 ? "notification/webhook"
451
452
  : "";
452
453
  const pos = (risk.flow_position ?? 0) >= 5
453
- ? "an entry point"
454
+ ? "entry point"
454
455
  : (risk.flow_position ?? 0) >= 3
455
- ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from the nearest entry point`
456
+ ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from an entry point`
456
457
  : "deep in the call graph";
457
- const churn = risk.churn_available !== false
458
- ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days`
459
- : "Git churn unavailable (provisional static-only ranking)";
460
- const parts = [
461
- `Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
462
- `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.`
458
+ const sink = risk.sink_callee;
459
+ const scheduled = risk.scheduled_entry === true;
460
+ const churnKnown = risk.churn_available !== false;
461
+ const churn = churnKnown
462
+ ? (risk.git_churn > 0 ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days` : "unchanged in 180 days")
463
+ : "change history unavailable";
464
+ const tier = risk.detection_tier ?? "";
465
+ const evidence = tier === "candidate"
466
+ ? "no test links here (a similarly-named test exists but never calls it)"
467
+ : tier === "associated" ? "a test calls it but nothing proves it fails when broken" : "no test links here";
468
+ const sinkShort = sink ? sink.split(".").pop() ?? sink : "";
469
+ // Line 1 — the consequence, in plain English, from the signals only.
470
+ const lead = sink && scheduled
471
+ ? `Runs on a schedule and can ${sinkShort.toLowerCase().startsWith("purge") ? "purge" : "delete"} data — nothing proves it does the right thing.`
472
+ : sink
473
+ ? `Can ${sinkShort.toLowerCase().startsWith("purge") ? "purge" : "delete"} data and nothing proves it works.`
474
+ : scheduled
475
+ ? "Runs on a schedule with no proof — a failure here surfaces nowhere."
476
+ : sens
477
+ ? `Sits on ${sens} paths, changes, and nothing proves it.`
478
+ : "Reachable and changing, with nothing proving it.";
479
+ // Line 2 — what the graph saw. Facts only: names, counts, tiers.
480
+ const seen = [
481
+ pos + (sens ? ` on ${sens} paths` : ""),
482
+ ...(sink ? [`reaches \`${sink}\` within two calls`] : []),
483
+ ...(scheduled ? ["scheduled / queue-triggered"] : []),
484
+ `${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}`,
485
+ churn,
486
+ evidence
463
487
  ];
488
+ // Line 3 — what would close it: a test SHAPE, never a claim that one exists.
489
+ const close = sink
490
+ ? `one test that drives the path to \`${sinkShort}\` and fails when the guard before it is broken.`
491
+ : scheduled
492
+ ? "one test that runs this entry against a mutated dependency and fails."
493
+ : "one test that exercises this behavior and fails when it is mutated.";
494
+ const scoreWords = `ORS ${risk.risk_score} — ${(risk.probability ?? 0) >= 6 ? "changes often" : (risk.probability ?? 0) >= 3 ? "changes some" : "stable"} (${risk.probability ?? "?"}) × ${sink ? "irreversible" : (risk.impact ?? 0) >= 6 ? "high blast radius" : "moderate impact"} (${risk.impact ?? "?"}) × ${(risk.detection_difficulty ?? 0) >= 9 ? "unproven and silent" : "unproven"} (${risk.detection_difficulty ?? "?"})`;
495
+ const overrides = (risk.reasons ?? []).filter((r) => r.startsWith("config override"));
496
+ const parts = [lead, `Seen: ${seen.join(" · ")}.`, `Would close it: ${close}`, ...overrides.map((o) => `${o[0].toUpperCase()}${o.slice(1)}.`), scoreWords + "."];
464
497
  return parts.join(" ");
465
498
  }
466
499
  /** Pure delta between a persisted baseline and the current report data.
@@ -483,13 +516,14 @@ export function computeReportDelta(prev, cur) {
483
516
  droppedRisks,
484
517
  generatedDelta: cur.generatedTotal - prev.generatedTotal
485
518
  };
519
+ d.configChanged = Boolean(prev.configHash && cur.provenance?.configHash && prev.configHash !== cur.provenance.configHash);
486
520
  d.changed =
487
521
  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;
522
+ d.noneDelta !== 0 || d.generatedDelta !== 0 || newRisks.length > 0 || droppedRisks.length > 0 || d.configChanged;
489
523
  return d;
490
524
  }
491
525
  export function reportBaselineOf(cur, ts) {
492
- return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal };
526
+ return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal, configHash: cur.provenance?.configHash };
493
527
  }
494
528
  const HTTP_TRIGGER_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"]);
495
529
  const GRAPHQL_TRIGGER_VERBS = new Set(["MUTATION", "QUERY", "SUBSCRIPTION"]);
@@ -892,6 +926,18 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
892
926
  .filter((row) => row.evidence_tier === "proven" && Boolean(row.code_symbol))
893
927
  .map((row) => row.code_symbol));
894
928
  const riskGaps = rankPriorityGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, provenIds });
929
+ // Two worklists from ONE ranking, no new weights. A multiplicative P×I×D top-N
930
+ // cannot hold "changing fast, unproven" and "irreversible, stable, unproven" on
931
+ // the same page: with P=1 for stable code, one family always erases the other
932
+ // (verified three ways on Temporal). Same rows, two questions.
933
+ const wide = rankRiskGaps(graph, { repoRoot, limit: 200, provenIds });
934
+ const changeFrontier = [...wide]
935
+ .filter((r) => !r.sink_callee)
936
+ .sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0) || b.risk_score - a.risk_score || a.id.localeCompare(b.id))
937
+ .slice(0, 20)
938
+ .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, probability: r.probability ?? 0 }));
939
+ const irreversible = wide.filter((r) => r.sink_callee).slice(0, 20)
940
+ .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, sink: r.sink_callee ?? "" }));
895
941
  const riskHealth = inspectRiskInputHealth(repoRoot);
896
942
  const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
897
943
  const provenance = {
@@ -902,6 +948,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
902
948
  churn: churnAvailable ? "available" : "unavailable",
903
949
  churnWindow: riskHealth.churnWindow,
904
950
  toolVersion: ORANGEPRO_VERSION,
951
+ configHash: loadRiskConfig(repoRoot).hash,
905
952
  inputFingerprint: createHash("sha256")
906
953
  .update(JSON.stringify({ root: graph.workspace.root_hash, commit: riskHealth.commit, history: riskHealth.history, churn: churnAvailable, window: riskHealth.churnWindow, version: ORANGEPRO_VERSION }))
907
954
  .digest("hex")
@@ -928,6 +975,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
928
975
  flows: flowRows,
929
976
  candidateFlows: candidateFlows(graph),
930
977
  risks,
978
+ worklists: { changeFrontier, irreversible },
931
979
  zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
932
980
  mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
933
981
  viewMeta: {
@@ -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.36",
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",