@orangepro/orangepro-mcp 0.2.32 → 0.2.34

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.
@@ -2169,7 +2169,10 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2169
2169
  const systemPrompt = opts.systemPrompt ?? buildSystemPrompt();
2170
2170
  const promptVersion = inputMode === "graph_grounded" && opts.prompt_version === "v5" ? PROMPT_VERSION_V5 : PROMPT_VERSION;
2171
2171
  const created_at = clock();
2172
- const runSeed = shortHash(created_at + provider.modelName + runTargets.map((t) => t.external_id).join(","));
2172
+ const runSeed = shortHash(created_at +
2173
+ provider.modelName +
2174
+ runTargets.map((t) => t.external_id).join(",") +
2175
+ JSON.stringify(opts.existing_generated_test_titles ?? []));
2173
2176
  const run_id = `local-gen-${runSeed}`;
2174
2177
  const generated = [];
2175
2178
  const missing = [];
@@ -2287,6 +2290,10 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2287
2290
  if (generated.length >= limit)
2288
2291
  break;
2289
2292
  const gc = gatherContext(graph, behavior, framework, fileReader);
2293
+ const existingGeneratedTitles = dedupe(opts.existing_generated_test_titles ?? []);
2294
+ if (existingGeneratedTitles.length) {
2295
+ gc.ctx.existing_tests = dedupe([...gc.ctx.existing_tests, ...existingGeneratedTitles]);
2296
+ }
2290
2297
  reportProgress(`Planning "${gc.ctx.behavior_title}" [v5]…`);
2291
2298
  let scenarios = [];
2292
2299
  // Transport first: a network/timeout failure is NOT malformed JSON, so it does
@@ -2312,7 +2319,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2312
2319
  continue;
2313
2320
  }
2314
2321
  try {
2315
- const result = parsePlannedScenariosStrict(rawPlan, 20);
2322
+ const result = parsePlannedScenariosStrict(rawPlan, 2);
2316
2323
  scenarios = result.scenarios;
2317
2324
  if (result.dropped > 0) {
2318
2325
  warnings.push(`Dropped ${result.dropped} invalid v5 planned scenario(s) for "${gc.ctx.behavior_title}": ${result.dropSummary.join("; ")}.`);
@@ -2343,7 +2350,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2343
2350
  maxTokens: 1600,
2344
2351
  temperature: 0
2345
2352
  });
2346
- const result = parsePlannedScenariosStrict(repaired, 20);
2353
+ const result = parsePlannedScenariosStrict(repaired, 2);
2347
2354
  // Keep ONLY repaired scenarios that tie back to the ORIGINAL malformed text; drop any the
2348
2355
  // model invented. If none tie back, fail closed — never generate from an invented plan.
2349
2356
  const tiedBack = result.scenarios.filter((s) => scenarioTiesBackToRaw(s, rawPlan));
@@ -2377,6 +2384,19 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2377
2384
  continue;
2378
2385
  }
2379
2386
  }
2387
+ if (existingGeneratedTitles.length) {
2388
+ const normalizedExistingTitles = new Set(existingGeneratedTitles.map((title) => title.trim().toLowerCase()));
2389
+ const beforeDuplicateFilter = scenarios.length;
2390
+ scenarios = scenarios.filter((scenario) => {
2391
+ const scenarioTitle = scenario.title.trim().toLowerCase();
2392
+ const fullTitle = `${gc.ctx.behavior_title} — ${scenario.title}`.trim().toLowerCase();
2393
+ return !normalizedExistingTitles.has(scenarioTitle) && !normalizedExistingTitles.has(fullTitle);
2394
+ });
2395
+ const duplicateCount = beforeDuplicateFilter - scenarios.length;
2396
+ if (duplicateCount > 0) {
2397
+ warnings.push(`Dropped ${duplicateCount} already-generated v5 scenario(s) for "${gc.ctx.behavior_title}" during top-up.`);
2398
+ }
2399
+ }
2380
2400
  if (scenarios.length === 0) {
2381
2401
  missing.push({
2382
2402
  external_id: behavior.external_id,
@@ -2393,6 +2413,36 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2393
2413
  // with several scenarios, leaving later high-risk behaviors untouched.
2394
2414
  const targetLimit = explicitMulti ? Math.max(1, Math.floor(remainingSlots / remainingTargets)) : remainingSlots;
2395
2415
  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
+ };
2396
2446
  const completions = [];
2397
2447
  try {
2398
2448
  reportProgress(`Generating "${gc.ctx.behavior_title}" [v5 batch: ${selected.length}]…`);
@@ -2422,6 +2472,8 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2422
2472
  const scenarioById = new Map(selected.map((s) => [s.id, s]));
2423
2473
  const parsed = completions.flatMap(parseBatchGeneratedTests);
2424
2474
  const seenScenarioIds = new Set();
2475
+ const emittedScenarioIds = new Set();
2476
+ const failureReasonByScenarioId = new Map();
2425
2477
  const relatedFiles = relatedFilePaths(graph, behavior).files;
2426
2478
  for (let i = 0; i < parsed.length && generated.length < limit; i++) {
2427
2479
  const parsedTest = parsed[i];
@@ -2455,23 +2507,27 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2455
2507
  warnings.push(`Redacted ${sanitized.redactedLines} echoed source-excerpt line(s) from the v5 generated test for "${gc.ctx.behavior_title}".`);
2456
2508
  }
2457
2509
  if (!hasExecutableContent(sanitized.body, framework)) {
2510
+ const reason = `V5 generated no executable code for scenario "${scenario.title}".`;
2458
2511
  warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": no executable test code.`);
2459
2512
  missing.push({
2460
2513
  external_id: behavior.external_id,
2461
2514
  title: gc.ctx.behavior_title,
2462
- reason: `V5 generated no executable code for scenario "${scenario.title}".`,
2515
+ reason,
2463
2516
  needed: ["a non-empty runnable test body"]
2464
2517
  });
2518
+ failureReasonByScenarioId.set(scenario.id, reason);
2465
2519
  continue;
2466
2520
  }
2467
2521
  if (!generatedBodyAlignsWithScenario(sanitized.body, scenario)) {
2522
+ const reason = `V5 generated test did not align with scenario "${scenario.title}".`;
2468
2523
  warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": body did not reference the planned assertion target.`);
2469
2524
  missing.push({
2470
2525
  external_id: behavior.external_id,
2471
2526
  title: gc.ctx.behavior_title,
2472
- reason: `V5 generated test did not align with scenario "${scenario.title}".`,
2527
+ reason,
2473
2528
  needed: ["a generated test body that asserts the planned scenario target"]
2474
2529
  });
2530
+ failureReasonByScenarioId.set(scenario.id, reason);
2475
2531
  continue;
2476
2532
  }
2477
2533
  let cleanBody = sanitized.body;
@@ -2540,43 +2596,8 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2540
2596
  reason,
2541
2597
  needed: ["a compiling generated test with a real assertion and resolvable subject import"]
2542
2598
  });
2543
- // Preserve the grounded INTENT in English, never the rejected code.
2544
- // The scenario fields were authored by a model that saw source
2545
- // excerpts — scrub the composed body with the same guard as code.
2546
- // The scenario plan (title / assertion targets / rationale) is the
2547
- // reviewable half of the draft; withholding the body entirely also
2548
- // removes any residual source-echo risk. runnable:false + the reason
2549
- // keep this honestly a draft — it ships with no run command and can
2550
- // never enter the proof-ready set.
2551
- const manualBody = sanitizeGeneratedBody([
2552
- `Scenario: ${scenario.title}`,
2553
- ...(scenario.steps && scenario.steps.length
2554
- ? ["Steps:", ...scenario.steps.map((st, n) => ` ${n + 1}. ${st}`)]
2555
- : []),
2556
- ...(scenario.test_data ? [`Test data: ${scenario.test_data}`] : []),
2557
- ...(scenario.assertion_targets.length ? [`Expected: ${scenario.assertion_targets.join("; ")}`] : []),
2558
- ...(scenario.rationale ? [`Why this test: ${scenario.rationale}`] : []),
2559
- "",
2560
- // Concise blocker: first clause only — the full remedy is one line.
2561
- `Blocked by: ${reason.split(" — ")[0]}`,
2562
- `Fix: ${generatedDraftRemediation(reason)}`
2563
- ].join("\n"), gc.ctx.source_excerpts, "//").body;
2564
- generated.push({
2565
- id: `${run_id}-t${generated.length + 1}`,
2566
- run_id,
2567
- title: `${gc.ctx.behavior_title} — ${scenario.title}`,
2568
- test_type: gc.ctx.test_layer,
2569
- framework_hint: framework,
2570
- body: manualBody,
2571
- bucket: bucketForV5Scenario(scenario),
2572
- prompt_version: PROMPT_VERSION_V5,
2573
- grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
2574
- weak_evidence_used: false,
2575
- target_symbol_external_id: behavior.external_id,
2576
- ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
2577
- runnable: false,
2578
- unresolved_reason: reason
2579
- });
2599
+ generated.push(manualDraftForScenario(scenario, reason));
2600
+ emittedScenarioIds.add(scenario.id);
2580
2601
  continue;
2581
2602
  }
2582
2603
  generated.push({
@@ -2600,6 +2621,17 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2600
2621
  : {}),
2601
2622
  runnable: true
2602
2623
  });
2624
+ emittedScenarioIds.add(scenario.id);
2625
+ }
2626
+ // A valid plan is still useful when code generation is empty, malformed,
2627
+ // or rejected. Retain that grounded scenario as an honest manual test so
2628
+ // the report never loses the test idea merely because code was unusable.
2629
+ for (const scenario of selected) {
2630
+ if (generated.length >= limit || emittedScenarioIds.has(scenario.id))
2631
+ continue;
2632
+ const reason = failureReasonByScenarioId.get(scenario.id) ??
2633
+ `No accepted codified test was returned for planned scenario "${scenario.title}".`;
2634
+ generated.push(manualDraftForScenario(scenario, reason));
2603
2635
  }
2604
2636
  }
2605
2637
  }
@@ -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
  "",
@@ -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
  }
@@ -1630,36 +1669,67 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1630
1669
  else if (!opts.noAuto && generationProviderConfigured && opts.ai !== false && generationLimit > 0) {
1631
1670
  try {
1632
1671
  const graphForGeneration = loadGraph(workspacePaths(root).graphPath);
1633
- const generatedTargets = new Set((graphForGeneration.generated_tests ?? []).map((t) => t.target_symbol_external_id).filter((id) => Boolean(id)));
1634
- const targetIds = rankPriorityGaps(graphForGeneration, {
1672
+ const generatedTestsByTarget = new Map();
1673
+ for (const test of graphForGeneration.generated_tests ?? []) {
1674
+ const targetId = test.target_symbol_external_id;
1675
+ if (!targetId || test.stale === true)
1676
+ continue;
1677
+ const tests = generatedTestsByTarget.get(targetId) ?? [];
1678
+ tests.push(test);
1679
+ generatedTestsByTarget.set(targetId, tests);
1680
+ }
1681
+ const targetPlans = rankPriorityGaps(graphForGeneration, {
1635
1682
  repoRoot: root,
1636
1683
  limit: generationLimit,
1637
1684
  provenIds: provenSymbolIds(graphForGeneration, loadLedger(root))
1638
1685
  })
1639
- .map((gap) => gap.id)
1640
- .filter((id) => !generatedTargets.has(id));
1641
- if (!targetIds.length) {
1686
+ .map((gap) => {
1687
+ const existingTests = generatedTestsByTarget.get(gap.id) ?? [];
1688
+ return {
1689
+ id: gap.id,
1690
+ existingTests,
1691
+ deficit: Math.max(0, 2 - existingTests.length)
1692
+ };
1693
+ })
1694
+ .filter((target) => target.deficit > 0);
1695
+ if (!targetPlans.length) {
1642
1696
  generationResult = {
1643
1697
  ...generationResult,
1644
1698
  status: "no_targets",
1645
- reason: "No eligible ungenerated risk targets were found."
1699
+ reason: "Every eligible priority flow already has two generated tests."
1646
1700
  };
1647
1701
  }
1648
1702
  else {
1649
- reportProgress(`generate: drafting tests for top ${targetIds.length} risk target(s)`, { current: 6, total: 8 });
1703
+ reportProgress(`generate: filling test gaps for ${targetPlans.length} priority flow(s)`, { current: 6, total: 8 });
1650
1704
  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);
1653
- const generated = await opGenerate(root, {
1654
- ...providerOpts,
1655
- target_ids: batch,
1656
- limit: batch.length,
1657
- // The offline deterministic stand-in emits the established v2 scaffold; v5 is
1658
- // a two-phase model planning protocol and must not be selected implicitly for it.
1659
- prompt_version: opts.promptVersion ?? (deterministicGeneration ? "v2" : "v5")
1660
- }, providerDeps);
1661
- generatedDrafts.push(...generated.generated_tests);
1662
- warnings.push(...generated.warnings.map((w) => `generate: ${w}`));
1705
+ const incompleteTargets = [];
1706
+ for (const target of targetPlans) {
1707
+ let remaining = target.deficit;
1708
+ const existingTitles = target.existingTests.map((test) => test.title);
1709
+ // A provider can return one accepted scenario when two were requested.
1710
+ // Make at most one bounded follow-up for the remaining slot, carrying
1711
+ // prior titles so the planner cannot silently duplicate the first test.
1712
+ for (let attempt = 0; attempt < 2 && remaining > 0; attempt++) {
1713
+ const generated = await opGenerate(root, {
1714
+ ...providerOpts,
1715
+ target_ids: [target.id],
1716
+ limit: remaining,
1717
+ pin_unchanged: false,
1718
+ existing_generated_test_titles: existingTitles,
1719
+ // The offline deterministic stand-in emits the established v2 scaffold; v5 is
1720
+ // a two-phase model planning protocol and must not be selected implicitly for it.
1721
+ prompt_version: opts.promptVersion ?? (deterministicGeneration ? "v2" : "v5")
1722
+ }, providerDeps);
1723
+ const freshForTarget = generated.generated_tests.filter((test) => test.target_symbol_external_id === target.id && !test.pinned);
1724
+ generatedDrafts.push(...freshForTarget);
1725
+ warnings.push(...generated.warnings.map((w) => `generate: ${w}`));
1726
+ if (freshForTarget.length === 0)
1727
+ break;
1728
+ existingTitles.push(...freshForTarget.map((test) => test.title));
1729
+ remaining = Math.max(0, remaining - freshForTarget.length);
1730
+ }
1731
+ if (remaining > 0)
1732
+ incompleteTargets.push(target.id);
1663
1733
  }
1664
1734
  const blockers = {};
1665
1735
  for (const draft of generatedDrafts.filter((test) => test.runnable === false)) {
@@ -1667,22 +1737,27 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1667
1737
  blockers[blocker] = (blockers[blocker] ?? 0) + 1;
1668
1738
  }
1669
1739
  const runnable = generatedDrafts.filter((test) => test.runnable !== false).length;
1740
+ const shortfallReason = incompleteTargets.length
1741
+ ? `${incompleteTargets.length} priority flow(s) remain below two generated tests because the provider returned no additional accepted distinct scenario.`
1742
+ : undefined;
1670
1743
  generationResult = {
1671
1744
  status: generatedDrafts.length === 0
1672
1745
  ? "no_results"
1673
- : generatedDrafts.some((test) => test.runnable === false)
1746
+ : generatedDrafts.some((test) => test.runnable === false) || incompleteTargets.length > 0
1674
1747
  ? "completed_with_blockers"
1675
1748
  : "completed",
1676
- requested: targetIds.length,
1749
+ requested: targetPlans.length,
1677
1750
  generated: generatedDrafts.length,
1678
1751
  runnable,
1679
1752
  drafts: generatedDrafts.length - runnable,
1680
1753
  blockers,
1681
1754
  ...(generatedDrafts.length === 0
1682
- ? { reason: "The provider returned no generated-test drafts for the selected risk targets." }
1683
- : {})
1755
+ ? { reason: shortfallReason ?? "The provider returned no generated-test drafts for the selected risk targets." }
1756
+ : shortfallReason
1757
+ ? { reason: shortfallReason }
1758
+ : {})
1684
1759
  };
1685
- if (generatedDrafts.length === 0)
1760
+ if (generationResult.reason)
1686
1761
  warnings.push(`generate: ${generationResult.reason}`);
1687
1762
  }
1688
1763
  }
@@ -1856,7 +1931,6 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1856
1931
  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
1932
  const START_RTM_LIMIT = 500;
1858
1933
  const START_GENERATE_RISK_LIMIT = 20;
1859
- const START_GENERATE_BATCH_LIMIT = 5;
1860
1934
  const EMPTY_EVIDENCE_SUMMARY = {
1861
1935
  tests: 0,
1862
1936
  tests_with_proof: 0,
@@ -1909,6 +1983,7 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1909
1983
  limit: opts.limit,
1910
1984
  input_mode: opts.input_mode,
1911
1985
  prompt_version: opts.prompt_version,
1986
+ existing_generated_test_titles: opts.existing_generated_test_titles,
1912
1987
  // Persisting lane: a runnable draft for an unchanged target is reused as-is
1913
1988
  // rather than re-bought from the model on every run.
1914
1989
  pin_unchanged: opts.pin_unchanged ?? true
@@ -1925,8 +2000,8 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1925
2000
  };
1926
2001
  saveGraph(paths.graphPath, next);
1927
2002
  // 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
2003
+ // tests immediately. A later analyze/start preserves them only while their
2004
+ // exact target fingerprint remains current. Display-only refresh; a render
1930
2005
  // failure must never fail generate.
1931
2006
  try {
1932
2007
  opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`, undefined, { persistBaseline: false });
@@ -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)),
@@ -935,8 +935,8 @@ function riskMatchesFilter(r){
935
935
  function renderRiskFilters(){
936
936
  const options=[
937
937
  ["all","All",D.risks.length],
938
- ["generated","Flows with generated output",generatedRiskCount+"/"+D.risks.length],
939
- ["missing","Flows without generated output",D.risks.filter(r=>!(r.generatedTests&&r.generatedTests.length)).length+"/"+D.risks.length]
938
+ ["generated","Flows with tests",generatedRiskCount],
939
+ ["missing","No generated tests",D.risks.filter(r=>!(r.generatedTests&&r.generatedTests.length)).length]
940
940
  ];
941
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("");
942
942
  }
@@ -989,7 +989,7 @@ function renderRisks(){
989
989
  riskList.append(el("div","paywall",
990
990
  hiddenGeneratedFlows
991
991
  ? \`<div class="paywall-num">\${hiddenGeneratedFlows} more flows with generated output</div>
992
- <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>
993
993
  <a class="paywall-btn" href="https://orangepro.ai/get-started" target="_blank">View all on OrangePro Platform &rarr;</a>\`
994
994
  : remainingRiskFlows
995
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.32",
3
+ "version": "0.2.34",
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",