@orangepro/orangepro-mcp 0.2.31 → 0.2.33

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.
@@ -10,7 +10,7 @@ export function classifyGeneratedDraftBlocker(reason) {
10
10
  if (/no required module provides package|cannot find module|module not found|unresolved import|imports module-path package/i.test(text)) {
11
11
  return "unresolved_import";
12
12
  }
13
- if (/syntax check failed|undefined:|unknown field|redeclared in this block|expected (?:declaration|operand|'[^']+'|"[^"]+"|[^ ]+),? found|literal not terminated|cannot assign/i.test(text))
13
+ if (/syntax check failed|undefined:|unknown field|redeclared in this block|expected (?:declaration|operand|'[^']+'|"[^"]+"|[^ ]+),? found|literal not terminated|cannot assign|cannot use .+ as |missing ['",].+argument list/i.test(text))
14
14
  return "generated_code";
15
15
  return "unknown";
16
16
  }
@@ -1515,7 +1515,13 @@ function commandAvailable(command) {
1515
1515
  }
1516
1516
  }
1517
1517
  function shortStaticDiag(message) {
1518
- return message.replace(/\s+/g, " ").trim().slice(0, 240);
1518
+ const compact = message.replace(/\s+/g, " ").trim();
1519
+ if (compact.length <= 240)
1520
+ return compact;
1521
+ // Compiler output starts with package/build boilerplate and puts the useful
1522
+ // file:line diagnostic at the end. Preserve both ends so classification and
1523
+ // remediation do not degrade to "unknown" on long package names.
1524
+ return `${compact.slice(0, 72)} … ${compact.slice(-165)}`;
1519
1525
  }
1520
1526
  function escapeRegExp(value) {
1521
1527
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -2158,6 +2164,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2158
2164
  const framework = pickFramework(graph, opts, targets, fileReader);
2159
2165
  const runSelection = targetsForFramework(graph, targets, framework);
2160
2166
  let runTargets = runSelection.targets;
2167
+ const explicitMulti = Boolean(opts.target_ids && opts.target_ids.length > 1);
2161
2168
  warnings.push(...runSelection.warnings);
2162
2169
  const systemPrompt = opts.systemPrompt ?? buildSystemPrompt();
2163
2170
  const promptVersion = inputMode === "graph_grounded" && opts.prompt_version === "v5" ? PROMPT_VERSION_V5 : PROMPT_VERSION;
@@ -2275,7 +2282,8 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2275
2282
  }
2276
2283
  else if (opts.prompt_version === "v5") {
2277
2284
  const declaredDeps = readDeclaredDeps(graph.workspace.root);
2278
- for (const behavior of runTargets) {
2285
+ for (let targetIndex = 0; targetIndex < runTargets.length; targetIndex++) {
2286
+ const behavior = runTargets[targetIndex];
2279
2287
  if (generated.length >= limit)
2280
2288
  break;
2281
2289
  const gc = gatherContext(graph, behavior, framework, fileReader);
@@ -2304,7 +2312,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2304
2312
  continue;
2305
2313
  }
2306
2314
  try {
2307
- const result = parsePlannedScenariosStrict(rawPlan, 20);
2315
+ const result = parsePlannedScenariosStrict(rawPlan, 2);
2308
2316
  scenarios = result.scenarios;
2309
2317
  if (result.dropped > 0) {
2310
2318
  warnings.push(`Dropped ${result.dropped} invalid v5 planned scenario(s) for "${gc.ctx.behavior_title}": ${result.dropSummary.join("; ")}.`);
@@ -2335,7 +2343,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2335
2343
  maxTokens: 1600,
2336
2344
  temperature: 0
2337
2345
  });
2338
- const result = parsePlannedScenariosStrict(repaired, 20);
2346
+ const result = parsePlannedScenariosStrict(repaired, 2);
2339
2347
  // Keep ONLY repaired scenarios that tie back to the ORIGINAL malformed text; drop any the
2340
2348
  // model invented. If none tie back, fail closed — never generate from an invented plan.
2341
2349
  const tiedBack = result.scenarios.filter((s) => scenarioTiesBackToRaw(s, rawPlan));
@@ -2378,7 +2386,43 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2378
2386
  });
2379
2387
  continue;
2380
2388
  }
2381
- const selected = scenarios.slice(0, Math.max(1, limit - generated.length));
2389
+ const remainingSlots = Math.max(1, limit - generated.length);
2390
+ const remainingTargets = Math.max(1, runTargets.length - targetIndex);
2391
+ // In explicit multi-target mode, reserve a fair share for every remaining
2392
+ // target. Previously the first behavior could consume the entire batch
2393
+ // with several scenarios, leaving later high-risk behaviors untouched.
2394
+ const targetLimit = explicitMulti ? Math.max(1, Math.floor(remainingSlots / remainingTargets)) : remainingSlots;
2395
+ 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
+ };
2382
2426
  const completions = [];
2383
2427
  try {
2384
2428
  reportProgress(`Generating "${gc.ctx.behavior_title}" [v5 batch: ${selected.length}]…`);
@@ -2408,6 +2452,8 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2408
2452
  const scenarioById = new Map(selected.map((s) => [s.id, s]));
2409
2453
  const parsed = completions.flatMap(parseBatchGeneratedTests);
2410
2454
  const seenScenarioIds = new Set();
2455
+ const emittedScenarioIds = new Set();
2456
+ const failureReasonByScenarioId = new Map();
2411
2457
  const relatedFiles = relatedFilePaths(graph, behavior).files;
2412
2458
  for (let i = 0; i < parsed.length && generated.length < limit; i++) {
2413
2459
  const parsedTest = parsed[i];
@@ -2441,23 +2487,27 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2441
2487
  warnings.push(`Redacted ${sanitized.redactedLines} echoed source-excerpt line(s) from the v5 generated test for "${gc.ctx.behavior_title}".`);
2442
2488
  }
2443
2489
  if (!hasExecutableContent(sanitized.body, framework)) {
2490
+ const reason = `V5 generated no executable code for scenario "${scenario.title}".`;
2444
2491
  warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": no executable test code.`);
2445
2492
  missing.push({
2446
2493
  external_id: behavior.external_id,
2447
2494
  title: gc.ctx.behavior_title,
2448
- reason: `V5 generated no executable code for scenario "${scenario.title}".`,
2495
+ reason,
2449
2496
  needed: ["a non-empty runnable test body"]
2450
2497
  });
2498
+ failureReasonByScenarioId.set(scenario.id, reason);
2451
2499
  continue;
2452
2500
  }
2453
2501
  if (!generatedBodyAlignsWithScenario(sanitized.body, scenario)) {
2502
+ const reason = `V5 generated test did not align with scenario "${scenario.title}".`;
2454
2503
  warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": body did not reference the planned assertion target.`);
2455
2504
  missing.push({
2456
2505
  external_id: behavior.external_id,
2457
2506
  title: gc.ctx.behavior_title,
2458
- reason: `V5 generated test did not align with scenario "${scenario.title}".`,
2507
+ reason,
2459
2508
  needed: ["a generated test body that asserts the planned scenario target"]
2460
2509
  });
2510
+ failureReasonByScenarioId.set(scenario.id, reason);
2461
2511
  continue;
2462
2512
  }
2463
2513
  let cleanBody = sanitized.body;
@@ -2526,43 +2576,8 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2526
2576
  reason,
2527
2577
  needed: ["a compiling generated test with a real assertion and resolvable subject import"]
2528
2578
  });
2529
- // Preserve the grounded INTENT in English, never the rejected code.
2530
- // The scenario fields were authored by a model that saw source
2531
- // excerpts — scrub the composed body with the same guard as code.
2532
- // The scenario plan (title / assertion targets / rationale) is the
2533
- // reviewable half of the draft; withholding the body entirely also
2534
- // removes any residual source-echo risk. runnable:false + the reason
2535
- // keep this honestly a draft — it ships with no run command and can
2536
- // never enter the proof-ready set.
2537
- const manualBody = sanitizeGeneratedBody([
2538
- `Scenario: ${scenario.title}`,
2539
- ...(scenario.steps && scenario.steps.length
2540
- ? ["Steps:", ...scenario.steps.map((st, n) => ` ${n + 1}. ${st}`)]
2541
- : []),
2542
- ...(scenario.test_data ? [`Test data: ${scenario.test_data}`] : []),
2543
- ...(scenario.assertion_targets.length ? [`Expected: ${scenario.assertion_targets.join("; ")}`] : []),
2544
- ...(scenario.rationale ? [`Why this test: ${scenario.rationale}`] : []),
2545
- "",
2546
- // Concise blocker: first clause only — the full remedy is one line.
2547
- `Blocked by: ${reason.split(" — ")[0]}`,
2548
- `Fix: ${generatedDraftRemediation(reason)}`
2549
- ].join("\n"), gc.ctx.source_excerpts, "//").body;
2550
- generated.push({
2551
- id: `${run_id}-t${generated.length + 1}`,
2552
- run_id,
2553
- title: `${gc.ctx.behavior_title} — ${scenario.title}`,
2554
- test_type: gc.ctx.test_layer,
2555
- framework_hint: framework,
2556
- body: manualBody,
2557
- bucket: bucketForV5Scenario(scenario),
2558
- prompt_version: PROMPT_VERSION_V5,
2559
- grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
2560
- weak_evidence_used: false,
2561
- target_symbol_external_id: behavior.external_id,
2562
- ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
2563
- runnable: false,
2564
- unresolved_reason: reason
2565
- });
2579
+ generated.push(manualDraftForScenario(scenario, reason));
2580
+ emittedScenarioIds.add(scenario.id);
2566
2581
  continue;
2567
2582
  }
2568
2583
  generated.push({
@@ -2586,12 +2601,22 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2586
2601
  : {}),
2587
2602
  runnable: true
2588
2603
  });
2604
+ emittedScenarioIds.add(scenario.id);
2605
+ }
2606
+ // A valid plan is still useful when code generation is empty, malformed,
2607
+ // or rejected. Retain that grounded scenario as an honest manual test so
2608
+ // the report never loses the test idea merely because code was unusable.
2609
+ for (const scenario of selected) {
2610
+ if (generated.length >= limit || emittedScenarioIds.has(scenario.id))
2611
+ continue;
2612
+ const reason = failureReasonByScenarioId.get(scenario.id) ??
2613
+ `No accepted codified test was returned for planned scenario "${scenario.title}".`;
2614
+ generated.push(manualDraftForScenario(scenario, reason));
2589
2615
  }
2590
2616
  }
2591
2617
  }
2592
2618
  else {
2593
2619
  // Default: target-focused, bucket-diverse generation (one test per local bucket).
2594
- const explicitMulti = Boolean(opts.target_ids && opts.target_ids.length > 1);
2595
2620
  const plan = planGroundedBuckets(graph, runTargets, framework, fileReader, limit, explicitMulti, missing, warnings);
2596
2621
  // Repo dependency names (read once) — used by the runnable check to tell a missing
2597
2622
  // baseUrl-local import from a genuine external package the agent has installed.
@@ -74,8 +74,8 @@ export function buildPlanningSystemPromptV5() {
74
74
  "- Only propose scenarios justified by evidence. No evidence = skip concern.",
75
75
  "- Each scenario must be distinct.",
76
76
  "- Existing tests are already covered. Never re-propose them.",
77
- "- Rank all scenarios by risk_rank (1 = highest blast radius × likelihood × coverage absence).",
78
- "- Find all gaps. No cap. If evidence justifies 3, output 3. If 30, output 30.",
77
+ "- Rank scenarios by risk_rank (1 = highest blast radius × likelihood × coverage absence).",
78
+ "- Return at most 2 scenarios: the two most critical missing tests justified by the evidence.",
79
79
  "- When FLOW CHAIN exists, prioritize gaps at service boundaries.",
80
80
  `- technique must be exactly one of: ${Object.keys(TECHNIQUE_DESC).join(", ")}.`,
81
81
  "",
@@ -19,7 +19,7 @@ import { enrichFromContent } from "./enrich/index.js";
19
19
  import { scoreGraph } from "./score/score.js";
20
20
  import { doctorGraph } from "./score/doctor.js";
21
21
  import { findGaps } from "./gaps/gaps.js";
22
- import { rankRiskGaps } from "./score/risk.js";
22
+ import { rankPriorityGaps, rankRiskGaps } from "./score/risk.js";
23
23
  import { generateTests } from "./generate/generator.js";
24
24
  import { classifyGeneratedDraftBlocker } from "./generate/draftGuidance.js";
25
25
  import { autoProve, NO_KEY_MESSAGE, isEligibleProvableTarget } from "./autoProve.js";
@@ -548,6 +548,45 @@ function preserveCandidateFlows(graphPath, graph) {
548
548
  return graph;
549
549
  }
550
550
  }
551
+ /**
552
+ * Carry generated tests across a deterministic re-analysis only while the exact
553
+ * target still exists and its source fingerprint is unchanged. This keeps
554
+ * `opro start` additive for unchanged code without presenting an old draft as
555
+ * current after its target changes or disappears.
556
+ */
557
+ function preserveGeneratedArtifacts(graphPath, graph) {
558
+ let previous;
559
+ try {
560
+ if (!existsSync(graphPath))
561
+ return graph;
562
+ previous = loadGraph(graphPath);
563
+ }
564
+ catch {
565
+ return graph;
566
+ }
567
+ const currentSymbols = new Set(graph.nodes.filter((node) => node.kind === "CodeSymbol").map((node) => node.external_id));
568
+ const byId = new Map();
569
+ for (const test of previous.generated_tests ?? []) {
570
+ const target = test.target_symbol_external_id;
571
+ if (!target || !currentSymbols.has(target) || !test.target_fingerprint || test.stale === true)
572
+ continue;
573
+ if (targetFingerprint(graph, target) !== test.target_fingerprint)
574
+ continue;
575
+ if (!byId.has(test.id))
576
+ byId.set(test.id, { ...test, pinned: undefined });
577
+ }
578
+ const generatedTests = [...byId.values()];
579
+ if (generatedTests.length === 0)
580
+ return graph;
581
+ const retainedIds = new Set(generatedTests.map((test) => test.id));
582
+ const generationRuns = (previous.generation_runs ?? [])
583
+ .map((run) => ({
584
+ ...run,
585
+ generated_test_ids: run.generated_test_ids.filter((id) => retainedIds.has(id))
586
+ }))
587
+ .filter((run) => run.generated_test_ids.length > 0);
588
+ return { ...graph, generation_runs: generationRuns, generated_tests: generatedTests };
589
+ }
551
590
  export function opAnalyze(root, opts = {}, deps = defaultDeps()) {
552
591
  const now = deps.clock();
553
592
  const paths = workspacePaths(root);
@@ -605,7 +644,7 @@ export function opAnalyze(root, opts = {}, deps = defaultDeps()) {
605
644
  // Applied AI candidate flows must SURVIVE re-analysis, but the stored lane is
606
645
  // untrusted (any process can rewrite graph.json) — preserve+re-validate it
607
646
  // without ever letting a malformed lane fail analyze.
608
- const graph = preserveCandidateFlows(paths.graphPath, builtGraph);
647
+ const graph = preserveGeneratedArtifacts(paths.graphPath, preserveCandidateFlows(paths.graphPath, builtGraph));
609
648
  if (!opts.suppressProgress) {
610
649
  reportProgress("analyze: writing graph.json", { current: opts.generateCoverage ? 4 : 3, total: opts.generateCoverage ? 4 : 3 });
611
650
  }
@@ -1631,7 +1670,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1631
1670
  try {
1632
1671
  const graphForGeneration = loadGraph(workspacePaths(root).graphPath);
1633
1672
  const generatedTargets = new Set((graphForGeneration.generated_tests ?? []).map((t) => t.target_symbol_external_id).filter((id) => Boolean(id)));
1634
- const targetIds = rankRiskGaps(graphForGeneration, {
1673
+ const targetIds = rankPriorityGaps(graphForGeneration, {
1635
1674
  repoRoot: root,
1636
1675
  limit: generationLimit,
1637
1676
  provenIds: provenSymbolIds(graphForGeneration, loadLedger(root))
@@ -1648,12 +1687,13 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1648
1687
  else {
1649
1688
  reportProgress(`generate: drafting tests for top ${targetIds.length} risk target(s)`, { current: 6, total: 8 });
1650
1689
  const generatedDrafts = [];
1651
- for (let i = 0; i < targetIds.length; i += START_GENERATE_BATCH_LIMIT) {
1652
- const batch = targetIds.slice(i, i + START_GENERATE_BATCH_LIMIT);
1690
+ for (const targetId of targetIds) {
1653
1691
  const generated = await opGenerate(root, {
1654
1692
  ...providerOpts,
1655
- target_ids: batch,
1656
- limit: batch.length,
1693
+ // One ranked flow per generation run. Give each flow room for its
1694
+ // two highest-risk missing tests instead of sharing a batch budget.
1695
+ target_ids: [targetId],
1696
+ limit: 2,
1657
1697
  // The offline deterministic stand-in emits the established v2 scaffold; v5 is
1658
1698
  // a two-phase model planning protocol and must not be selected implicitly for it.
1659
1699
  prompt_version: opts.promptVersion ?? (deterministicGeneration ? "v2" : "v5")
@@ -1856,7 +1896,6 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1856
1896
  const NO_PROVIDER_MESSAGE = 'No model provider configured. Set OPENAI_API_KEY (or OLLAMA_BASE_URL / ANTHROPIC_API_KEY) in your shell environment or a .env.provider.local file to generate with your own model, or pass provider="deterministic" (or set ORANGEPRO_ALLOW_DETERMINISTIC=1) to use the offline deterministic stand-in. No tests were generated.';
1857
1897
  const START_RTM_LIMIT = 500;
1858
1898
  const START_GENERATE_RISK_LIMIT = 20;
1859
- const START_GENERATE_BATCH_LIMIT = 5;
1860
1899
  const EMPTY_EVIDENCE_SUMMARY = {
1861
1900
  tests: 0,
1862
1901
  tests_with_proof: 0,
@@ -1925,8 +1964,8 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1925
1964
  };
1926
1965
  saveGraph(paths.graphPath, next);
1927
1966
  // Keep the behavior report in sync with the freshly persisted generated
1928
- // tests analyze/start would REBUILD the graph and drop them, so this is
1929
- // the only command that can surface them. Display-only refresh; a render
1967
+ // tests immediately. A later analyze/start preserves them only while their
1968
+ // exact target fingerprint remains current. Display-only refresh; a render
1930
1969
  // failure must never fail generate.
1931
1970
  try {
1932
1971
  opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`, undefined, { persistBaseline: false });
@@ -580,3 +580,11 @@ export function rankRiskGaps(graph, opts = {}) {
580
580
  .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
581
  .slice(0, limit);
582
582
  }
583
+ /**
584
+ * Canonical priority-gap portfolio shown to a local user and used for automatic
585
+ * generation. Keeping this policy in one function prevents `opro start` from
586
+ * generating for a different "top N" than behavior-coverage.html displays.
587
+ */
588
+ export function rankPriorityGaps(graph, opts = {}) {
589
+ return rankRiskGaps(graph, { ...opts, maxPerFile: 3, maxPerTitle: 1 });
590
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { buildRtm } from "../rtm.js";
4
- import { inspectRiskInputHealth, isEntryPoint, rankRiskGaps } from "../score/risk.js";
4
+ import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps } from "../score/risk.js";
5
5
  import { ORANGEPRO_VERSION } from "../version.js";
6
6
  import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
7
7
  import { classifyGeneratedDraftBlocker } from "../generate/draftGuidance.js";
@@ -413,12 +413,10 @@ function riskGeneratedTests(graph, gap, riskIds, isFirstRowForFile) {
413
413
  return [];
414
414
  return fileOf(t.target_symbol_external_id) === gap.file ? [{ t, sameFile: true }] : [];
415
415
  });
416
- // Mutually exclusive display: when ANY runnable generated test exists for
417
- // this target, English intents are suppressed intents are strictly the
418
- // fallback for environments where runnable code was withheld. Never mix.
419
- const runnableLinked = linked.filter(({ t }) => t.runnable !== false);
420
- const shown = runnableLinked.length > 0 ? runnableLinked : linked;
421
- return shown.slice(0, 2).map(({ t, sameFile }) => ({
416
+ // Keep the two generated scenarios visible even when only one survives
417
+ // compile validation. Hiding the manual sibling makes an unchanged rerun
418
+ // appear to have lost a generated test and obscures the real blocker.
419
+ return linked.slice(0, 2).map(({ t, sameFile }) => ({
422
420
  name: t.title,
423
421
  concern: t.test_type && t.test_type !== "unknown" ? t.test_type : undefined,
424
422
  bucket: t.bucket,
@@ -436,11 +434,6 @@ function riskGeneratedTests(graph, gap, riskIds, isFirstRowForFile) {
436
434
  ...(t.runnable === false ? { blocker: classifyGeneratedDraftBlocker(t.unresolved_reason) } : {})
437
435
  }));
438
436
  }
439
- /** Incoming refs are method-attributed and can be fractional when a file-level
440
- * reference is split across its symbols. Preserve that weighting honestly. */
441
- function fmtRefs(n) {
442
- return Number.isInteger(n) ? String(n) : n.toFixed(1);
443
- }
444
437
  function displayTitle(title, file) {
445
438
  if (title.includes("."))
446
439
  return title;
@@ -461,13 +454,12 @@ function riskContext(risk) {
461
454
  : (risk.flow_position ?? 0) >= 3
462
455
  ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from the nearest entry point`
463
456
  : "deep in the call graph";
464
- const refs = fmtRefs(risk.incoming_refs);
465
457
  const churn = risk.churn_available !== false
466
458
  ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days`
467
459
  : "Git churn unavailable (provisional static-only ranking)";
468
460
  const parts = [
469
461
  `Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
470
- `${refs} weighted incoming reference${risk.incoming_refs === 1 ? "" : "s"}, ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${churn} — and no test proves its behavior.`
462
+ `ORS ${risk.risk_score} (P${risk.probability ?? "?"} × I${risk.impact ?? "?"} × D${risk.detection_difficulty ?? "?"}) reflects flow position, change activity, complexity, impact, and test evidence; ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${churn} — and no test proves this flow.`
471
463
  ];
472
464
  return parts.join(" ");
473
465
  }
@@ -749,7 +741,7 @@ function riskApplicableConcerns(risk, verb) {
749
741
  out.add("authorization_safety");
750
742
  if (sens >= 6)
751
743
  out.add("data_integrity");
752
- if (risk.entry_point || verb !== "BEHAVIOR")
744
+ if (risk.entry_point || verb !== "FLOW")
753
745
  out.add("boundary_limits"); // external inputs cross here
754
746
  if ((risk.flow_position ?? 0) >= 3 || (risk.fan_out ?? 0) >= 1)
755
747
  out.add("integration_flow");
@@ -775,6 +767,10 @@ function riskTodo(risk, verb, path, generatedTests) {
775
767
  if (generatedTests.length && generatedTests.every((t) => t.runnable !== false)) {
776
768
  return "Run the generated test below in your repo; follow its prove handoff so a mutation failure can mint Dynamically Proven.";
777
769
  }
770
+ if (generatedTests.some((t) => t.runnable !== false) &&
771
+ generatedTests.some((t) => t.runnable === false)) {
772
+ return "Run the validated generated test below, and review the second scenario's blocker before repairing or regenerating its draft. Both generated scenarios remain visible so an unchanged rerun does not appear to lose work.";
773
+ }
778
774
  if (generatedTests.length) {
779
775
  const blockers = new Set(generatedTests.map((t) => t.blocker ?? "unknown"));
780
776
  if (blockers.size === 1) {
@@ -794,7 +790,7 @@ function riskTodo(risk, verb, path, generatedTests) {
794
790
  }
795
791
  return "OrangePro withheld generated code for the reasons shown below. Review each blocker, then repair or regenerate the drafts; do not assume repository dependencies are missing.";
796
792
  }
797
- const call = verb !== "BEHAVIOR"
793
+ const call = verb !== "FLOW"
798
794
  ? `issues ${verb} ${path}`
799
795
  : risk.entry_point
800
796
  ? `invokes ${displayTitle(risk.title, risk.file)} through its entry point`
@@ -843,14 +839,14 @@ function riskRows(risks, graph) {
843
839
  tags.push(["provisional rank", "info"]);
844
840
  else if (bucket)
845
841
  tags.push([`${bucket} risk`, "risk"]);
846
- tags.push([`${fmtRefs(risk.incoming_refs)} weighted refs`, "info"]);
842
+ tags.push([`ORS ${risk.risk_score}`, "info"]);
847
843
  if (risk.entry_point)
848
844
  tags.push(["Entry point", "entry"]);
849
845
  return {
850
846
  rank: idx + 1,
851
847
  ...(() => {
852
848
  const generatedTests = riskGeneratedTests(graph, risk, riskIds, firstRowForFile.get(risk.file) === risk.id);
853
- const verb = methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR";
849
+ const verb = methodMatch?.[1]?.toUpperCase() ?? "FLOW";
854
850
  const path = qualify(risk, methodMatch?.[2] ?? displayTitle(risk.title, risk.file));
855
851
  const generatedCategories = [...new Set([
856
852
  ...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
@@ -892,7 +888,10 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
892
888
  const flowIds = flowSymbolIds(graph);
893
889
  const summary = summaryFromRows(rows, flowIds);
894
890
  const repoRoot = opts.repoRoot ?? graph.workspace.root;
895
- const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, maxPerFile: 3, maxPerTitle: 1 });
891
+ const provenIds = new Set(rows
892
+ .filter((row) => row.evidence_tier === "proven" && Boolean(row.code_symbol))
893
+ .map((row) => row.code_symbol));
894
+ const riskGaps = rankPriorityGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, provenIds });
896
895
  const riskHealth = inspectRiskInputHealth(repoRoot);
897
896
  const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
898
897
  const provenance = {
@@ -911,16 +911,20 @@ if(D.viewMeta){
911
911
  if(rm&&rm.scored>rm.shown)$("#risk-cap-note").textContent="Showing the top "+rm.shown+" of "+rm.scored.toLocaleString()+" scored behaviors — every behavior is scored; only the highest-risk are surfaced here. Full ranking: opro gaps --limit N, or .orangepro/graph.json.";
912
912
  if(fm&&fm.shown>0&&fm.prunedByCaps>0)$("#flow-cap-note").textContent="Showing "+fm.shown.toLocaleString()+" flows, endpoint-anchored first. "+fm.prunedByCaps.toLocaleString()+" additional branch expansions were pruned by depth/branch/global rendering caps — pruning affects display only, not scoring.";
913
913
  }
914
- // Platform CTA: top banner in risk panel
915
- const riskTopBanner=el("div","platform-top-banner",
916
- \`<span class="platform-top-banner-text">Local scan shows <b>\${D.risks.length}</b> priority gaps. Full ranked list, incident correlation, and CI merge gate on Platform.</span>
917
- <a class="platform-footer-btn" href="https://orangepro.ai/get-started" target="_blank">Unlock Full Analysis &rarr;</a>\`);
918
- riskList.before(riskTopBanner);
919
914
  const generatedRiskCount=D.risks.filter(r=>r.generatedTests&&r.generatedTests.length).length;
920
915
  const generatedOutputCopy=[
921
916
  D.generatedRunnableTotal?\`\${D.generatedRunnableTotal} runnable generated test\${D.generatedRunnableTotal===1?"":"s"}\`:'',
922
917
  D.generatedDraftTotal?\`\${D.generatedDraftTotal} grounded draft\${D.generatedDraftTotal===1?"":"s"} with code withheld\`:'',
923
918
  ].filter(Boolean).join(' and ');
919
+ const generationSummary=D.generatedTotal
920
+ ?\` Generated output: <b>\${generatedOutputCopy}</b>; <b>\${D.shownCount}</b> shown inline across <b>\${generatedRiskCount} of \${D.risks.length}</b> priority flows.\`
921
+ :'';
922
+ // Platform CTA: top banner in risk panel. Keep generated-output totals beside
923
+ // the flow count so a user cannot mistake "5 flows" for "5 generated tests".
924
+ const riskTopBanner=el("div","platform-top-banner",
925
+ \`<span class="platform-top-banner-text">Local scan shows <b>\${D.risks.length}</b> priority gaps.\${generationSummary} Full ranked list, incident correlation, and CI merge gate on Platform.</span>
926
+ <a class="platform-footer-btn" href="https://orangepro.ai/get-started" target="_blank">Unlock Full Analysis &rarr;</a>\`);
927
+ riskList.before(riskTopBanner);
924
928
  let activeRiskFilter=generatedRiskCount?"generated":"all";
925
929
  function riskMatchesFilter(r){
926
930
  const hasGenerated=Boolean(r.generatedTests&&r.generatedTests.length);
@@ -931,7 +935,7 @@ function riskMatchesFilter(r){
931
935
  function renderRiskFilters(){
932
936
  const options=[
933
937
  ["all","All",D.risks.length],
934
- ["generated","Flows with generated output",generatedRiskCount],
938
+ ["generated","Flows with tests",generatedRiskCount],
935
939
  ["missing","No generated tests",D.risks.filter(r=>!(r.generatedTests&&r.generatedTests.length)).length]
936
940
  ];
937
941
  riskTools.innerHTML=options.map(([key,label,count])=>\`<button class="risk-filter" type="button" data-risk-filter="\${key}" aria-pressed="\${key===activeRiskFilter}">\${label} <span class="gc">\${count}</span></button>\`).join("");
@@ -985,7 +989,7 @@ function renderRisks(){
985
989
  riskList.append(el("div","paywall",
986
990
  hiddenGeneratedFlows
987
991
  ? \`<div class="paywall-num">\${hiddenGeneratedFlows} more flows with generated output</div>
988
- <div class="paywall-txt">OrangePro produced \${generatedOutputCopy} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Use the “Flows with generated output” filter to review them first.</div>
992
+ <div class="paywall-txt">OrangePro produced \${generatedOutputCopy} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Use the “Flows with tests” filter to review them first.</div>
989
993
  <a class="paywall-btn" href="https://orangepro.ai/get-started" target="_blank">View all on OrangePro Platform &rarr;</a>\`
990
994
  : remainingRiskFlows
991
995
  ? \`<div class="paywall-num">\${remainingRiskFlows} high-risk flows left</div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
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",