@orangepro/orangepro-mcp 0.2.33 → 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 +1 -0
- package/dist/local/analyze/analyzer.js +66 -1
- package/dist/local/analyze/treeSitter/engine.js +12 -2
- package/dist/local/cli.js +3 -1
- package/dist/local/generate/generator.js +113 -32
- package/dist/local/generate/providers.js +41 -6
- package/dist/local/interactive.js +4 -2
- package/dist/local/localConfig.js +2 -2
- package/dist/local/operations.js +115 -25
- package/dist/local/score/risk.js +115 -11
- package/dist/local/score/riskConfig.js +62 -0
- package/dist/local/viz/behaviorReportData.js +9 -2
- package/docs/local-proof-kit.md +9 -0
- package/package.json +1 -1
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" &&
|
|
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-
|
|
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) {
|
|
@@ -2169,7 +2169,10 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2169
2169
|
const systemPrompt = opts.systemPrompt ?? buildSystemPrompt();
|
|
2170
2170
|
const promptVersion = inputMode === "graph_grounded" && opts.prompt_version === "v5" ? PROMPT_VERSION_V5 : PROMPT_VERSION;
|
|
2171
2171
|
const created_at = clock();
|
|
2172
|
-
const runSeed = shortHash(created_at +
|
|
2172
|
+
const runSeed = shortHash(created_at +
|
|
2173
|
+
provider.modelName +
|
|
2174
|
+
runTargets.map((t) => t.external_id).join(",") +
|
|
2175
|
+
JSON.stringify(opts.existing_generated_test_titles ?? []));
|
|
2173
2176
|
const run_id = `local-gen-${runSeed}`;
|
|
2174
2177
|
const generated = [];
|
|
2175
2178
|
const missing = [];
|
|
@@ -2287,8 +2290,92 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2287
2290
|
if (generated.length >= limit)
|
|
2288
2291
|
break;
|
|
2289
2292
|
const gc = gatherContext(graph, behavior, framework, fileReader);
|
|
2293
|
+
const existingGeneratedTitles = dedupe(opts.existing_generated_test_titles ?? []);
|
|
2294
|
+
if (existingGeneratedTitles.length) {
|
|
2295
|
+
gc.ctx.existing_tests = dedupe([...gc.ctx.existing_tests, ...existingGeneratedTitles]);
|
|
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
|
+
};
|
|
2290
2376
|
reportProgress(`Planning "${gc.ctx.behavior_title}" [v5]…`);
|
|
2291
2377
|
let scenarios = [];
|
|
2378
|
+
let emptyScenarioReason = "V5 planning returned no missing scenarios.";
|
|
2292
2379
|
// Transport first: a network/timeout failure is NOT malformed JSON, so it does
|
|
2293
2380
|
// not warrant a repair pass — fail closed and emit no test.
|
|
2294
2381
|
let rawPlan;
|
|
@@ -2309,6 +2396,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2309
2396
|
reason: `V5 planning call failed: ${msg}`,
|
|
2310
2397
|
needed: ["a reachable model provider"]
|
|
2311
2398
|
});
|
|
2399
|
+
emitManualPlanningFallback(`V5 planning call failed: ${msg}.`);
|
|
2312
2400
|
continue;
|
|
2313
2401
|
}
|
|
2314
2402
|
try {
|
|
@@ -2316,6 +2404,9 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2316
2404
|
scenarios = result.scenarios;
|
|
2317
2405
|
if (result.dropped > 0) {
|
|
2318
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
|
+
}
|
|
2319
2410
|
}
|
|
2320
2411
|
}
|
|
2321
2412
|
catch (parseErr) {
|
|
@@ -2334,6 +2425,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2334
2425
|
reason: `V5 planning JSON had no recoverable scenario array (repair would invent): ${parseMsg}`,
|
|
2335
2426
|
needed: ["valid JSON planned scenarios"]
|
|
2336
2427
|
});
|
|
2428
|
+
emitManualPlanningFallback(`V5 planning JSON had no recoverable scenario array: ${parseMsg}.`);
|
|
2337
2429
|
continue;
|
|
2338
2430
|
}
|
|
2339
2431
|
try {
|
|
@@ -2356,6 +2448,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2356
2448
|
reason: "V5 planning repair produced only invented scenarios not tied to the original output.",
|
|
2357
2449
|
needed: ["valid JSON planned scenarios"]
|
|
2358
2450
|
});
|
|
2451
|
+
emitManualPlanningFallback("V5 planning repair produced no scenario tied to the original response.");
|
|
2359
2452
|
continue;
|
|
2360
2453
|
}
|
|
2361
2454
|
scenarios = tiedBack;
|
|
@@ -2374,16 +2467,34 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2374
2467
|
reason: `V5 planning JSON was malformed and could not be repaired: ${repairMsg}`,
|
|
2375
2468
|
needed: ["valid JSON planned scenarios"]
|
|
2376
2469
|
});
|
|
2470
|
+
emitManualPlanningFallback(`V5 planning JSON was malformed and repair failed: ${repairMsg}.`);
|
|
2377
2471
|
continue;
|
|
2378
2472
|
}
|
|
2379
2473
|
}
|
|
2474
|
+
if (existingGeneratedTitles.length) {
|
|
2475
|
+
const normalizedExistingTitles = new Set(existingGeneratedTitles.map((title) => title.trim().toLowerCase()));
|
|
2476
|
+
const beforeDuplicateFilter = scenarios.length;
|
|
2477
|
+
scenarios = scenarios.filter((scenario) => {
|
|
2478
|
+
const scenarioTitle = scenario.title.trim().toLowerCase();
|
|
2479
|
+
const fullTitle = `${gc.ctx.behavior_title} — ${scenario.title}`.trim().toLowerCase();
|
|
2480
|
+
return !normalizedExistingTitles.has(scenarioTitle) && !normalizedExistingTitles.has(fullTitle);
|
|
2481
|
+
});
|
|
2482
|
+
const duplicateCount = beforeDuplicateFilter - scenarios.length;
|
|
2483
|
+
if (duplicateCount > 0) {
|
|
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
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2380
2490
|
if (scenarios.length === 0) {
|
|
2381
2491
|
missing.push({
|
|
2382
2492
|
external_id: behavior.external_id,
|
|
2383
2493
|
title: gc.ctx.behavior_title,
|
|
2384
|
-
reason:
|
|
2494
|
+
reason: emptyScenarioReason,
|
|
2385
2495
|
needed: ["a distinct uncovered scenario"]
|
|
2386
2496
|
});
|
|
2497
|
+
emitManualPlanningFallback(emptyScenarioReason);
|
|
2387
2498
|
continue;
|
|
2388
2499
|
}
|
|
2389
2500
|
const remainingSlots = Math.max(1, limit - generated.length);
|
|
@@ -2393,36 +2504,6 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
|
|
|
2393
2504
|
// with several scenarios, leaving later high-risk behaviors untouched.
|
|
2394
2505
|
const targetLimit = explicitMulti ? Math.max(1, Math.floor(remainingSlots / remainingTargets)) : remainingSlots;
|
|
2395
2506
|
const selected = scenarios.slice(0, targetLimit);
|
|
2396
|
-
const manualDraftForScenario = (scenario, reason) => {
|
|
2397
|
-
const manualBody = sanitizeGeneratedBody([
|
|
2398
|
-
`Scenario: ${scenario.title}`,
|
|
2399
|
-
...(scenario.steps && scenario.steps.length
|
|
2400
|
-
? ["Steps:", ...scenario.steps.map((st, n) => ` ${n + 1}. ${st}`)]
|
|
2401
|
-
: []),
|
|
2402
|
-
...(scenario.test_data ? [`Test data: ${scenario.test_data}`] : []),
|
|
2403
|
-
...(scenario.assertion_targets.length ? [`Expected: ${scenario.assertion_targets.join("; ")}`] : []),
|
|
2404
|
-
...(scenario.rationale ? [`Why this test: ${scenario.rationale}`] : []),
|
|
2405
|
-
"",
|
|
2406
|
-
`Blocked by: ${reason.split(" — ")[0]}`,
|
|
2407
|
-
`Fix: ${generatedDraftRemediation(reason)}`
|
|
2408
|
-
].join("\n"), gc.ctx.source_excerpts, "//").body;
|
|
2409
|
-
return {
|
|
2410
|
-
id: `${run_id}-t${generated.length + 1}`,
|
|
2411
|
-
run_id,
|
|
2412
|
-
title: `${gc.ctx.behavior_title} — ${scenario.title}`,
|
|
2413
|
-
test_type: gc.ctx.test_layer,
|
|
2414
|
-
framework_hint: framework,
|
|
2415
|
-
body: manualBody,
|
|
2416
|
-
bucket: bucketForV5Scenario(scenario),
|
|
2417
|
-
prompt_version: PROMPT_VERSION_V5,
|
|
2418
|
-
grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
|
|
2419
|
-
weak_evidence_used: false,
|
|
2420
|
-
target_symbol_external_id: behavior.external_id,
|
|
2421
|
-
...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
|
|
2422
|
-
runnable: false,
|
|
2423
|
-
unresolved_reason: reason
|
|
2424
|
-
};
|
|
2425
|
-
};
|
|
2426
2507
|
const completions = [];
|
|
2427
2508
|
try {
|
|
2428
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
};
|
package/dist/local/operations.js
CHANGED
|
@@ -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,
|
|
@@ -1669,37 +1670,99 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
|
1669
1670
|
else if (!opts.noAuto && generationProviderConfigured && opts.ai !== false && generationLimit > 0) {
|
|
1670
1671
|
try {
|
|
1671
1672
|
const graphForGeneration = loadGraph(workspacePaths(root).graphPath);
|
|
1672
|
-
const
|
|
1673
|
-
const
|
|
1673
|
+
const generatedTestsByTarget = new Map();
|
|
1674
|
+
for (const test of graphForGeneration.generated_tests ?? []) {
|
|
1675
|
+
const targetId = test.target_symbol_external_id;
|
|
1676
|
+
if (!targetId || test.stale === true)
|
|
1677
|
+
continue;
|
|
1678
|
+
const tests = generatedTestsByTarget.get(targetId) ?? [];
|
|
1679
|
+
tests.push(test);
|
|
1680
|
+
generatedTestsByTarget.set(targetId, tests);
|
|
1681
|
+
}
|
|
1682
|
+
const rankedGenerationGaps = rankPriorityGaps(graphForGeneration, {
|
|
1674
1683
|
repoRoot: root,
|
|
1675
1684
|
limit: generationLimit,
|
|
1676
1685
|
provenIds: provenSymbolIds(graphForGeneration, loadLedger(root))
|
|
1686
|
+
});
|
|
1687
|
+
const targetPlans = rankedGenerationGaps
|
|
1688
|
+
.map((gap) => {
|
|
1689
|
+
const existingTests = generatedTestsByTarget.get(gap.id) ?? [];
|
|
1690
|
+
return {
|
|
1691
|
+
id: gap.id,
|
|
1692
|
+
existingTests,
|
|
1693
|
+
deficit: Math.max(0, 2 - existingTests.length)
|
|
1694
|
+
};
|
|
1677
1695
|
})
|
|
1678
|
-
.
|
|
1679
|
-
|
|
1680
|
-
if (!targetIds.length) {
|
|
1696
|
+
.filter((target) => target.deficit > 0);
|
|
1697
|
+
if (!targetPlans.length) {
|
|
1681
1698
|
generationResult = {
|
|
1682
1699
|
...generationResult,
|
|
1683
1700
|
status: "no_targets",
|
|
1684
|
-
reason: "
|
|
1701
|
+
reason: "Every eligible priority flow already has two generated tests."
|
|
1685
1702
|
};
|
|
1686
1703
|
}
|
|
1687
1704
|
else {
|
|
1688
|
-
reportProgress(`generate:
|
|
1705
|
+
reportProgress(`generate: filling test gaps for ${targetPlans.length} priority flow(s)`, { current: 6, total: 8 });
|
|
1689
1706
|
const generatedDrafts = [];
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1707
|
+
const incompleteTargets = [];
|
|
1708
|
+
const diagnosticsStartedAt = deps.clock();
|
|
1709
|
+
const flowDiagnostics = [];
|
|
1710
|
+
for (const target of targetPlans) {
|
|
1711
|
+
let remaining = target.deficit;
|
|
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
|
+
};
|
|
1722
|
+
// A provider can return one accepted scenario when two were requested.
|
|
1723
|
+
// Make at most one bounded follow-up for the remaining slot, carrying
|
|
1724
|
+
// prior titles so the planner cannot silently duplicate the first test.
|
|
1725
|
+
for (let attempt = 0; attempt < 2 && remaining > 0; attempt++) {
|
|
1726
|
+
const generated = await opGenerate(root, {
|
|
1727
|
+
...providerOpts,
|
|
1728
|
+
target_ids: [target.id],
|
|
1729
|
+
limit: remaining,
|
|
1730
|
+
pin_unchanged: false,
|
|
1731
|
+
existing_generated_test_titles: existingTitles,
|
|
1732
|
+
manual_planning_fallback: attempt === 1,
|
|
1733
|
+
// The offline deterministic stand-in emits the established v2 scaffold; v5 is
|
|
1734
|
+
// a two-phase model planning protocol and must not be selected implicitly for it.
|
|
1735
|
+
prompt_version: opts.promptVersion ?? (deterministicGeneration ? "v2" : "v5")
|
|
1736
|
+
}, providerDeps);
|
|
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
|
+
});
|
|
1754
|
+
generatedDrafts.push(...freshForTarget);
|
|
1755
|
+
warnings.push(...generated.warnings.map((w) => `generate: ${w}`));
|
|
1756
|
+
if (freshForTarget.length === 0)
|
|
1757
|
+
continue;
|
|
1758
|
+
existingTitles.push(...freshForTarget.map((test) => test.title));
|
|
1759
|
+
remaining = Math.max(0, remaining - freshForTarget.length);
|
|
1760
|
+
}
|
|
1761
|
+
if (remaining > 0)
|
|
1762
|
+
incompleteTargets.push(target.id);
|
|
1763
|
+
flowDiagnostic.final_tests = target.existingTests.length + target.deficit - remaining;
|
|
1764
|
+
flowDiagnostic.remaining_shortfall = remaining;
|
|
1765
|
+
flowDiagnostics.push(flowDiagnostic);
|
|
1703
1766
|
}
|
|
1704
1767
|
const blockers = {};
|
|
1705
1768
|
for (const draft of generatedDrafts.filter((test) => test.runnable === false)) {
|
|
@@ -1707,23 +1770,47 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
|
1707
1770
|
blockers[blocker] = (blockers[blocker] ?? 0) + 1;
|
|
1708
1771
|
}
|
|
1709
1772
|
const runnable = generatedDrafts.filter((test) => test.runnable !== false).length;
|
|
1773
|
+
const shortfallReason = incompleteTargets.length
|
|
1774
|
+
? `${incompleteTargets.length} priority flow(s) remain below two generated tests because the provider returned no additional accepted distinct scenario.`
|
|
1775
|
+
: undefined;
|
|
1710
1776
|
generationResult = {
|
|
1711
1777
|
status: generatedDrafts.length === 0
|
|
1712
1778
|
? "no_results"
|
|
1713
|
-
: generatedDrafts.some((test) => test.runnable === false)
|
|
1779
|
+
: generatedDrafts.some((test) => test.runnable === false) || incompleteTargets.length > 0
|
|
1714
1780
|
? "completed_with_blockers"
|
|
1715
1781
|
: "completed",
|
|
1716
|
-
requested:
|
|
1782
|
+
requested: targetPlans.length,
|
|
1717
1783
|
generated: generatedDrafts.length,
|
|
1718
1784
|
runnable,
|
|
1719
1785
|
drafts: generatedDrafts.length - runnable,
|
|
1720
1786
|
blockers,
|
|
1721
1787
|
...(generatedDrafts.length === 0
|
|
1722
|
-
? { reason: "The provider returned no generated-test drafts for the selected risk targets." }
|
|
1723
|
-
:
|
|
1788
|
+
? { reason: shortfallReason ?? "The provider returned no generated-test drafts for the selected risk targets." }
|
|
1789
|
+
: shortfallReason
|
|
1790
|
+
? { reason: shortfallReason }
|
|
1791
|
+
: {})
|
|
1724
1792
|
};
|
|
1725
|
-
if (
|
|
1793
|
+
if (generationResult.reason)
|
|
1726
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
|
+
}
|
|
1727
1814
|
}
|
|
1728
1815
|
}
|
|
1729
1816
|
catch (err) {
|
|
@@ -1882,6 +1969,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
|
1882
1969
|
ai_linked: aiLinked,
|
|
1883
1970
|
behavior_coverage_path: coverageHtml,
|
|
1884
1971
|
coverage_report_path: coverageReport,
|
|
1972
|
+
generation_diagnostics_path: generationDiagnosticsPath,
|
|
1885
1973
|
rtm,
|
|
1886
1974
|
changed,
|
|
1887
1975
|
gaps,
|
|
@@ -1948,6 +2036,8 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
|
|
|
1948
2036
|
limit: opts.limit,
|
|
1949
2037
|
input_mode: opts.input_mode,
|
|
1950
2038
|
prompt_version: opts.prompt_version,
|
|
2039
|
+
existing_generated_test_titles: opts.existing_generated_test_titles,
|
|
2040
|
+
manual_planning_fallback: opts.manual_planning_fallback,
|
|
1951
2041
|
// Persisting lane: a runnable draft for an unchanged target is reused as-is
|
|
1952
2042
|
// rather than re-bought from the model on every run.
|
|
1953
2043
|
pin_unchanged: opts.pin_unchanged ?? true
|
package/dist/local/score/risk.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
package/docs/local-proof-kit.md
CHANGED
|
@@ -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.
|
|
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",
|