@orangepro/orangepro-mcp 0.2.10 → 0.2.12

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.
@@ -11,6 +11,7 @@ import { GENERATED_DIR } from "./runHints.js";
11
11
  import { behaviorNodes, findNode, nodesByKind, priorityRank } from "../graph/factories.js";
12
12
  import { structurallyUnconfirmable } from "../graph/confirmable.js";
13
13
  import { shortHash } from "../util/hash.js";
14
+ import { targetFingerprint } from "../ledger.js";
14
15
  import { reportProgress } from "../util/progress.js";
15
16
  import { redactSecrets, redactSecretsPreservingLineCount } from "../util/redact.js";
16
17
  import { systemClock } from "../util/time.js";
@@ -2042,6 +2043,25 @@ function planGroundedBuckets(graph, targets, framework, fileReader, limit, expli
2042
2043
  }
2043
2044
  return plan;
2044
2045
  }
2046
+ /**
2047
+ * Previously generated drafts that may be reused verbatim for `behavior`: the
2048
+ * newest run's RUNNABLE, non-stale drafts recorded against the target's CURRENT
2049
+ * fingerprint. A missing fingerprint (unanalyzed file, unparseable id) pins
2050
+ * nothing — the conservative direction is to regenerate, never to serve a draft
2051
+ * whose target we cannot prove is unchanged.
2052
+ */
2053
+ function pinnedDraftsFor(graph, behavior, fingerprint) {
2054
+ if (!fingerprint || behavior.kind !== "CodeSymbol")
2055
+ return [];
2056
+ const matches = (graph.generated_tests ?? []).filter((t) => t.target_symbol_external_id === behavior.external_id &&
2057
+ t.target_fingerprint === fingerprint &&
2058
+ t.runnable !== false &&
2059
+ t.stale !== true);
2060
+ if (matches.length === 0)
2061
+ return [];
2062
+ const latestRunId = matches[matches.length - 1].run_id;
2063
+ return matches.filter((t) => t.run_id === latestRunId);
2064
+ }
2045
2065
  /**
2046
2066
  * Select the same target behaviors + gathered context for the A/B comparison, so
2047
2067
  * both arms target identical behaviors and differ ONLY in whether the KG evidence
@@ -2067,7 +2087,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2067
2087
  const { targets, warnings } = selectTargets(graph, opts);
2068
2088
  const framework = pickFramework(graph, opts, targets, fileReader);
2069
2089
  const runSelection = targetsForFramework(graph, targets, framework);
2070
- const runTargets = runSelection.targets;
2090
+ let runTargets = runSelection.targets;
2071
2091
  warnings.push(...runSelection.warnings);
2072
2092
  const systemPrompt = opts.systemPrompt ?? buildSystemPrompt();
2073
2093
  const promptVersion = inputMode === "graph_grounded" && opts.prompt_version === "v5" ? PROMPT_VERSION_V5 : PROMPT_VERSION;
@@ -2076,6 +2096,24 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2076
2096
  const run_id = `local-gen-${runSeed}`;
2077
2097
  const generated = [];
2078
2098
  const missing = [];
2099
+ // Pin: a target whose code is byte-identical to what a previously RUNNABLE draft
2100
+ // was written against gets that draft back verbatim — no model call, no new
2101
+ // persisted record, no drift in what the agent is told to run. Regeneration
2102
+ // resumes the moment the target's fingerprint changes.
2103
+ const fingerprintOf = (behavior) => behavior.kind === "CodeSymbol" ? targetFingerprint(graph, behavior.external_id) : undefined;
2104
+ if (opts.pin_unchanged && inputMode === "graph_grounded") {
2105
+ const remaining = [];
2106
+ for (const behavior of runTargets) {
2107
+ const pinned = pinnedDraftsFor(graph, behavior, fingerprintOf(behavior));
2108
+ if (pinned.length === 0 || generated.length >= limit) {
2109
+ remaining.push(behavior);
2110
+ continue;
2111
+ }
2112
+ generated.push(...pinned.slice(0, limit - generated.length).map((t) => ({ ...t, pinned: true })));
2113
+ warnings.push(`Reused ${pinned.length} pinned draft(s) for "${behavior.title || behavior.external_id}" — the target is unchanged since they were generated (no model call).`);
2114
+ }
2115
+ runTargets = remaining;
2116
+ }
2079
2117
  if (inputMode === "raw_prompt") {
2080
2118
  // Internal baseline only: broad sampling, one raw test per target (no buckets).
2081
2119
  // Cap ATTEMPTS (not successes): with empty completions skipped below, capping
@@ -2439,6 +2477,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2439
2477
  grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
2440
2478
  weak_evidence_used: false,
2441
2479
  target_symbol_external_id: behavior.external_id,
2480
+ ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
2442
2481
  runnable: false,
2443
2482
  unresolved_reason: reason
2444
2483
  });
@@ -2460,7 +2499,9 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2460
2499
  import_provenance
2461
2500
  },
2462
2501
  weak_evidence_used: gc.weakUsed.length > 0,
2463
- ...(behavior.kind === "CodeSymbol" ? { target_symbol_external_id: behavior.external_id } : {}),
2502
+ ...(behavior.kind === "CodeSymbol"
2503
+ ? { target_symbol_external_id: behavior.external_id, ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}) }
2504
+ : {}),
2464
2505
  runnable: true
2465
2506
  });
2466
2507
  }
@@ -2631,7 +2672,12 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2631
2672
  import_provenance
2632
2673
  },
2633
2674
  weak_evidence_used: item.weakUsed.length > 0,
2634
- ...(item.behavior.kind === "CodeSymbol" ? { target_symbol_external_id: item.behavior.external_id } : {}),
2675
+ ...(item.behavior.kind === "CodeSymbol"
2676
+ ? {
2677
+ target_symbol_external_id: item.behavior.external_id,
2678
+ ...(fingerprintOf(item.behavior) ? { target_fingerprint: fingerprintOf(item.behavior) } : {})
2679
+ }
2680
+ : {}),
2635
2681
  runnable,
2636
2682
  ...(unresolved_reason ? { unresolved_reason } : {})
2637
2683
  });
@@ -2640,7 +2686,11 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2640
2686
  if (targets.length === 0) {
2641
2687
  warnings.push("No behavior anchors available to target. Add requirements/templates or analyze a path with tests.");
2642
2688
  }
2643
- const run = generated.length
2689
+ // A pinned draft belongs to the run that FIRST produced it: it must not be
2690
+ // re-listed under this run_id (nor re-persisted), or every pin-only generation
2691
+ // would duplicate the same draft in the graph.
2692
+ const freshTests = generated.filter((t) => !t.pinned);
2693
+ const run = freshTests.length
2644
2694
  ? {
2645
2695
  run_id,
2646
2696
  model_provider: provider.providerName,
@@ -2648,7 +2698,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2648
2698
  input_mode: inputMode,
2649
2699
  prompt_version: promptVersion,
2650
2700
  created_at,
2651
- generated_test_ids: generated.map((t) => t.id)
2701
+ generated_test_ids: freshTests.map((t) => t.id)
2652
2702
  }
2653
2703
  : null;
2654
2704
  return { run, generated_tests: generated, missing_evidence: missing, warnings };
@@ -41,7 +41,7 @@ import { renderCoverageReport } from "./pack/coverageReport.js";
41
41
  import { confirmedCoverageByLayer } from "./score/coverage.js";
42
42
  import { prepareRuntimeCoverage } from "./analyze/coverageArtifacts.js";
43
43
  import { appendLedgerRecord, canReproveLanguage, loadLedger, ledgerStats, proofEdgesFor, reproveTarget, resolveTargetSymbol, targetFingerprint, targetLanguage } from "./ledger.js";
44
- import { buildRtm, renderRtmCsv, renderRtmMarkdown } from "./rtm.js";
44
+ import { buildRtm, provenSymbolIds, renderRtmCsv, renderRtmMarkdown } from "./rtm.js";
45
45
  import { buildProofDoctor, distillProofAttempts, loadProofAttempts, proofAttemptsFresh, writeProofAttempts } from "./proofDoctor.js";
46
46
  import { tryScopedReprove } from "./reprove/scoped.js";
47
47
  import { resolveContained, toWorkspaceRel } from "./reprove/paths.js";
@@ -811,7 +811,10 @@ export function opProofDoctor(root) {
811
811
  export function opGaps(root, opts = {}) {
812
812
  const graph = loadGraph(workspacePaths(root).graphPath);
813
813
  const gaps = findGaps(graph, opts);
814
- const topRiskGaps = rankRiskGaps(graph, { limit: opts.limit ?? 10, repoRoot: root }).map((gap) => ({
814
+ // Ledger proofs are invisible to the graph's hard edges; pass them so a type
815
+ // whose every method is Dynamically Proven is suppressed instead of ranked.
816
+ const provenIds = provenSymbolIds(graph, loadLedger(root));
817
+ const topRiskGaps = rankRiskGaps(graph, { limit: opts.limit ?? 10, repoRoot: root, provenIds }).map((gap) => ({
815
818
  external_id: gap.id,
816
819
  title: gap.title,
817
820
  file: gap.file,
@@ -1598,7 +1601,11 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1598
1601
  try {
1599
1602
  const graphForGeneration = loadGraph(workspacePaths(root).graphPath);
1600
1603
  const generatedTargets = new Set((graphForGeneration.generated_tests ?? []).map((t) => t.target_symbol_external_id).filter((id) => Boolean(id)));
1601
- const targetIds = rankRiskGaps(graphForGeneration, { repoRoot: root, limit: START_GENERATE_RISK_LIMIT })
1604
+ const targetIds = rankRiskGaps(graphForGeneration, {
1605
+ repoRoot: root,
1606
+ limit: START_GENERATE_RISK_LIMIT,
1607
+ provenIds: provenSymbolIds(graphForGeneration, loadLedger(root))
1608
+ })
1602
1609
  .map((gap) => gap.id)
1603
1610
  .filter((id) => !generatedTargets.has(id));
1604
1611
  if (targetIds.length) {
@@ -1792,12 +1799,24 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1792
1799
  };
1793
1800
  }
1794
1801
  const reader = fileReaderFor(graph.workspace.root);
1795
- const result = await generateTests(graph, { target_ids: opts.target_ids, framework: opts.framework, limit: opts.limit, input_mode: opts.input_mode, prompt_version: opts.prompt_version }, provider, reader, deps.clock);
1796
- if (result.run && result.generated_tests.length) {
1802
+ const result = await generateTests(graph, {
1803
+ target_ids: opts.target_ids,
1804
+ framework: opts.framework,
1805
+ limit: opts.limit,
1806
+ input_mode: opts.input_mode,
1807
+ prompt_version: opts.prompt_version,
1808
+ // Persisting lane: a runnable draft for an unchanged target is reused as-is
1809
+ // rather than re-bought from the model on every run.
1810
+ pin_unchanged: opts.pin_unchanged ?? true
1811
+ }, provider, reader, deps.clock);
1812
+ // Pinned drafts are already IN graph.generated_tests — appending them again
1813
+ // would duplicate a draft on every generation of an unchanged target.
1814
+ const freshTests = result.generated_tests.filter((t) => !t.pinned);
1815
+ if (result.run && freshTests.length) {
1797
1816
  const next = {
1798
1817
  ...graph,
1799
1818
  generation_runs: [...graph.generation_runs, result.run],
1800
- generated_tests: [...graph.generated_tests, ...result.generated_tests],
1819
+ generated_tests: [...graph.generated_tests, ...freshTests],
1801
1820
  updated_at: deps.clock()
1802
1821
  };
1803
1822
  saveGraph(paths.graphPath, next);
package/dist/local/rtm.js CHANGED
@@ -47,6 +47,21 @@ export function buildRtm(graph, ledger, opts = {}) {
47
47
  const rows = opts.limit && opts.limit > 0 ? filteredRows.slice(0, opts.limit) : filteredRows;
48
48
  return { summary: summarizeRows(baseRows, unionRows), rows, ...(opts.scope ? { scope: opts.scope } : {}) };
49
49
  }
50
+ /**
51
+ * CodeSymbol ids the RTM would call Dynamically Proven — the SAME selection
52
+ * buildRtm uses (`selectLedgerBySymbol(...).proven`), so proof judgment has one
53
+ * definition. Consumers outside the RTM (risk ranking's container suppression)
54
+ * need it because a proof leaves no graph edge and its target frequently sits
55
+ * outside the deterministic denominator.
56
+ */
57
+ export function provenSymbolIds(graph, ledger) {
58
+ const out = new Set();
59
+ for (const [symbol, selected] of selectLedgerBySymbol(ledger, graph)) {
60
+ if (selected.proven)
61
+ out.add(symbol);
62
+ }
63
+ return out;
64
+ }
50
65
  function inScope(node, targetSet, fileSet) {
51
66
  if (!targetSet && !fileSet)
52
67
  return true;
@@ -11,7 +11,37 @@ function symbolFile(n) {
11
11
  function symbolTitle(n) {
12
12
  return n.title ?? n.external_id.split("#")[1] ?? n.external_id;
13
13
  }
14
- function confirmedBehaviorIds(graph) {
14
+ /**
15
+ * Structural container → member children, read from the analyzer's own
16
+ * `properties.member_of` (the same signal src/local/reprove/scoped.ts uses).
17
+ * Id-prefix matching cannot do this job: `sym:f.ts#a.b` is a child of
18
+ * `sym:f.ts#a` only by string luck, and a dotted symbol name with no container
19
+ * node would be mis-parented. A container is resolved inside the child's OWN
20
+ * file: a same-named symbol elsewhere is a different thing, and mis-parenting
21
+ * here would suppress a genuine gap.
22
+ */
23
+ function containerChildren(graph) {
24
+ const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol");
25
+ const idByFileTitle = new Map();
26
+ for (const n of symbols)
27
+ idByFileTitle.set(`${symbolFile(n)}#${symbolTitle(n)}`, n.external_id);
28
+ const out = new Map();
29
+ for (const n of symbols) {
30
+ const memberOf = n.properties.member_of;
31
+ if (typeof memberOf !== "string" || memberOf === "")
32
+ continue;
33
+ const containerId = idByFileTitle.get(`${symbolFile(n)}#${memberOf}`);
34
+ if (!containerId || containerId === n.external_id)
35
+ continue;
36
+ const list = out.get(containerId);
37
+ if (list)
38
+ list.push(n.external_id);
39
+ else
40
+ out.set(containerId, [n.external_id]);
41
+ }
42
+ return out;
43
+ }
44
+ function confirmedBehaviorIds(graph, provenIds) {
15
45
  const ids = new Set();
16
46
  const nodeKinds = new Map(graph.nodes.map((n) => [n.external_id, n.kind]));
17
47
  for (const e of graph.edges) {
@@ -24,6 +54,19 @@ function confirmedBehaviorIds(graph) {
24
54
  if (nodeKinds.get(e.to_external_id) === "CodeSymbol" || nodeKinds.get(e.to_external_id) === "Requirement")
25
55
  ids.add(e.to_external_id);
26
56
  }
57
+ // A container type whose every method child is confirmed has no distinct
58
+ // untested surface left — suppress it from the gap ranking rather than
59
+ // listing it as an unlinked candidate above its own proven methods.
60
+ // "Confirmed" here means a hard static link OR a current ledger proof: the
61
+ // proof lane leaves no graph edge and usually sits outside the denominator,
62
+ // so a hard-edge-only check never fires for a dynamically proven type.
63
+ const eligible = new Set(graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true).map((n) => n.external_id));
64
+ for (const [containerId, children] of containerChildren(graph)) {
65
+ if (ids.has(containerId) || !eligible.has(containerId))
66
+ continue;
67
+ if (children.every((c) => ids.has(c) || provenIds?.has(c) === true))
68
+ ids.add(containerId);
69
+ }
27
70
  return ids;
28
71
  }
29
72
  const GIT_CHURN_BATCH = 200;
@@ -314,7 +357,7 @@ function candidateSignalIds(graph, candidateIds) {
314
357
  }
315
358
  export function rankRiskGaps(graph, opts = {}) {
316
359
  const limit = opts.limit ?? 20;
317
- const confirmed = confirmedBehaviorIds(graph);
360
+ const confirmed = confirmedBehaviorIds(graph, opts.provenIds);
318
361
  const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && !confirmed.has(n.external_id));
319
362
  const symbolIds = new Set(symbols.map((s) => s.external_id));
320
363
  const symbolsByFile = new Map();
@@ -409,7 +409,7 @@ function riskGeneratedTests(graph, gap, riskIds, isFirstRowForFile) {
409
409
  // the assertion line stays pure metadata (framework, same-file, disclosure).
410
410
  assertion: [
411
411
  sameFile ? "same-file target" : "",
412
- t.framework_hint,
412
+ t.framework_hint || (gap.file.endsWith(".go") ? "go" : ""),
413
413
  t.weak_evidence_used ? "weak evidence disclosed" : ""
414
414
  ]
415
415
  .filter(Boolean)
@@ -826,7 +826,7 @@ function riskRows(risks, graph) {
826
826
  ].filter((c) => Boolean(c)))];
827
827
  return {
828
828
  generatedTests,
829
- applicableCategories: riskApplicableConcerns(risk, verb),
829
+ applicableCategories: [...new Set([...riskApplicableConcerns(risk, verb), ...generatedCategories])],
830
830
  generatedCategories,
831
831
  verb,
832
832
  path,
@@ -758,11 +758,11 @@ function renderRisks(){
758
758
  hiddenGeneratedFlows
759
759
  ? \`<div class="paywall-num">\${hiddenGeneratedFlows} more flows with tests</div>
760
760
  <div class="paywall-txt">OrangePro accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Use the “Flows with tests” filter to review them first.</div>
761
- <a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">View all on OrangePro Platform &rarr;</a>\`
761
+ <a class="paywall-btn" href="https://platform.orangepro.ai/" target="_blank">View all on OrangePro Platform &rarr;</a>\`
762
762
  : remainingRiskFlows
763
763
  ? \`<div class="paywall-num">\${remainingRiskFlows} high-risk flows left</div>
764
764
  <div class="paywall-txt">The local MCP accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Generate the remaining high-risk flow tests on OrangePro Platform.</div>
765
- <a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">Generate remaining tests on Platform &rarr;</a>\`
765
+ <a class="paywall-btn" href="https://platform.orangepro.ai/" target="_blank">Generate remaining tests on Platform &rarr;</a>\`
766
766
  : \`<div class="paywall-num">All generated tests are shown</div>
767
767
  <div class="paywall-txt">OrangePro generated tests for every high-risk flow in this report, and every generated test is visible here.</div>\`));
768
768
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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",