@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 +1 -0
- package/dist/local/analyze/analyzer.js +74 -1
- package/dist/local/analyze/treeSitter/engine.js +12 -2
- package/dist/local/cli.js +3 -1
- package/dist/local/generate/generator.js +92 -31
- 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 +57 -3
- package/dist/local/score/risk.js +142 -11
- package/dist/local/score/riskConfig.js +63 -0
- package/dist/local/viz/behaviorReportData.js +59 -11
- 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
|
|
|
@@ -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" &&
|
|
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-
|
|
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:
|
|
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
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
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,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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
? "
|
|
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
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
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: {
|
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.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",
|