@orangepro/orangepro-mcp 0.2.4 → 0.2.6

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.
@@ -102,15 +102,16 @@ function flowSymbolIds(graph) {
102
102
  return ids;
103
103
  }
104
104
  function isNoneTier(tier) {
105
- return tier !== "proven" && tier !== "associated" && tier !== "runtime";
105
+ return tier !== "proven" && tier !== "associated" && tier !== "runtime" && tier !== "candidate";
106
106
  }
107
107
  function summaryFromRows(rows, flowIds) {
108
108
  const proven = rows.filter((r) => r.evidence_tier === "proven").length;
109
109
  const associated = rows.filter((r) => r.evidence_tier === "associated" || r.evidence_tier === "runtime").length;
110
+ const candidate = rows.filter((r) => r.evidence_tier === "candidate").length;
110
111
  const noneRows = rows.filter((r) => isNoneTier(r.evidence_tier));
111
112
  // DISPLAY-ONLY split of `none`: a none-tier symbol that shows up in a static flow is "Reachable Untested".
112
113
  const reachableUntested = noneRows.filter((r) => flowIds.has(r.behavior_id)).length;
113
- return { total: rows.length, proven, associated, none: noneRows.length, reachableUntested, noSignal: noneRows.length - reachableUntested };
114
+ return { total: rows.length, proven, associated, candidate, none: noneRows.length, reachableUntested, noSignal: noneRows.length - reachableUntested };
114
115
  }
115
116
  /** Verbatim 0-dynamic-proof explainer copy. Rendered only when summary.proven === 0. */
116
117
  const ZERO_PROOF_EXPLAINER = {
@@ -231,7 +232,13 @@ function behaviorLists(rows, flowIds) {
231
232
  const behaviors = rows.map((row) => {
232
233
  const group = groupOf(row.file);
233
234
  groups.set(group, (groups.get(group) ?? 0) + 1);
234
- const tier = row.evidence_tier === "proven" ? "proven" : row.evidence_tier === "associated" || row.evidence_tier === "runtime" ? "assoc" : "none";
235
+ const tier = row.evidence_tier === "proven"
236
+ ? "proven"
237
+ : row.evidence_tier === "associated" || row.evidence_tier === "runtime"
238
+ ? "assoc"
239
+ : row.evidence_tier === "candidate"
240
+ ? "candidate"
241
+ : "none";
235
242
  return {
236
243
  sig: row.behavior || row.code_symbol,
237
244
  group,
@@ -247,14 +254,25 @@ function behaviorLists(rows, flowIds) {
247
254
  behaviors
248
255
  };
249
256
  }
250
- function riskBucket(score) {
251
- if (score >= 500)
257
+ /**
258
+ * Severity is RELATIVE to this repo's own score distribution, mirroring the
259
+ * blast-radius tier table (>=0.75 of max -> Tier 0/critical, >=0.50 -> high,
260
+ * >=0.25 -> medium). Absolute cutoffs (old: 500/200) were calibrated to the
261
+ * pre-normalization scale and could never fire after ORS de-saturation — every
262
+ * risk rendered "medium". Relative bucketing also matches the methodology:
263
+ * ORS is a structural risk *indicator*, never an absolute risk claim.
264
+ */
265
+ function riskBucket(score, maxScore) {
266
+ if (score <= 0 || maxScore <= 0)
267
+ return null;
268
+ const rel = score / maxScore;
269
+ if (rel >= 0.75)
252
270
  return "critical";
253
- if (score >= 200)
271
+ if (rel >= 0.5)
254
272
  return "high";
255
- if (score > 0)
273
+ if (rel >= 0.25)
256
274
  return "medium";
257
- return null;
275
+ return "medium";
258
276
  }
259
277
  function flowWhy(flow, proof) {
260
278
  const tier = flow.flow_tier === "framework-derived: reachable" ? "framework-derived reachable" : "hard reachable";
@@ -262,6 +280,7 @@ function flowWhy(flow, proof) {
262
280
  return `This chain is ${tier}; ${proofText}. Reachability is static and is not an execution claim.`;
263
281
  }
264
282
  function flows(graph, rows, risks) {
283
+ const maxRiskScore = risks.reduce((m, r) => Math.max(m, r.risk_score), 0);
265
284
  const nodesById = new Map(graph.nodes.map((n) => [n.external_id, n]));
266
285
  const proven = new Set(rows.filter((r) => r.evidence_tier === "proven").map((r) => r.behavior_id));
267
286
  const riskById = new Map(risks.map((r) => [r.id, r]));
@@ -287,7 +306,7 @@ function flows(graph, rows, risks) {
287
306
  return {
288
307
  title: flow.entry_point.title || flow.entry_point.external_id,
289
308
  trigger,
290
- risk: risk ? riskBucket(risk.risk_score) : null,
309
+ risk: risk ? riskBucket(risk.risk_score, maxRiskScore) : null,
291
310
  proof,
292
311
  services,
293
312
  flow_tier: flow.flow_tier,
@@ -358,16 +377,346 @@ function riskGeneratedTests(graph, gap, riskIds, isFirstRowForFile) {
358
377
  return [];
359
378
  return fileOf(t.target_symbol_external_id) === gap.file ? [{ t, sameFile: true }] : [];
360
379
  });
361
- return linked.slice(0, 2).map(({ t, sameFile }) => ({
380
+ // Mutually exclusive display: when ANY runnable generated test exists for
381
+ // this target, English intents are suppressed — intents are strictly the
382
+ // fallback for environments where runnable code was withheld. Never mix.
383
+ const runnableLinked = linked.filter(({ t }) => t.runnable !== false);
384
+ const shown = runnableLinked.length > 0 ? runnableLinked : linked;
385
+ return shown.slice(0, 2).map(({ t, sameFile }) => ({
362
386
  name: t.title,
363
387
  concern: t.test_type && t.test_type !== "unknown" ? t.test_type : undefined,
364
- assertion: [sameFile ? "same-file target" : "", t.framework_hint, t.weak_evidence_used ? "weak evidence disclosed" : ""]
388
+ bucket: t.bucket,
389
+ // The "English intent" marker renders ONCE, as the badge next to the name —
390
+ // the assertion line stays pure metadata (framework, same-file, disclosure).
391
+ assertion: [
392
+ sameFile ? "same-file target" : "",
393
+ t.framework_hint,
394
+ t.weak_evidence_used ? "weak evidence disclosed" : ""
395
+ ]
365
396
  .filter(Boolean)
366
397
  .join(" · "),
367
- code: t.body
398
+ code: t.body,
399
+ runnable: t.runnable !== false
368
400
  }));
369
401
  }
402
+ /** Incoming refs are method-attributed and can be fractional (a file-level
403
+ * reference split across its symbols). Display rounds; sub-1 shows "<1". */
404
+ function fmtRefs(n) {
405
+ if (n > 0 && n < 1)
406
+ return "<1";
407
+ return String(Math.round(n));
408
+ }
409
+ /** Deterministic 1–2 line behavior context from graph facts only — no LLM.
410
+ * Sensitivity label mirrors deriveDataSensitivity's tiers. */
411
+ function riskContext(risk) {
412
+ const sens = (risk.data_sensitivity ?? 1) >= 10 ? "payment/billing-sensitive"
413
+ : (risk.data_sensitivity ?? 1) >= 9 ? "auth/session-sensitive"
414
+ : (risk.data_sensitivity ?? 1) >= 7 ? "order/transaction"
415
+ : (risk.data_sensitivity ?? 1) >= 6 ? "customer/user-data"
416
+ : (risk.data_sensitivity ?? 1) >= 3 ? "notification/webhook"
417
+ : "";
418
+ const pos = (risk.flow_position ?? 0) >= 5
419
+ ? "an entry point"
420
+ : (risk.flow_position ?? 0) >= 3
421
+ ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from the nearest entry point`
422
+ : "deep in the call graph";
423
+ const refs = fmtRefs(risk.incoming_refs);
424
+ const parts = [
425
+ `Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
426
+ `${refs} caller${refs === "1" ? "" : "s"}, ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days — and no test proves its behavior.`
427
+ ];
428
+ return parts.join(" ");
429
+ }
430
+ /** Pure delta between a persisted baseline and the current report data.
431
+ * Deterministic: same inputs, same delta. */
432
+ export function computeReportDelta(prev, cur) {
433
+ const curPaths = cur.risks.map((r) => r.path);
434
+ const prevSet = new Set(prev.riskPaths);
435
+ const curSet = new Set(curPaths);
436
+ const newRisks = curPaths.filter((p) => !prevSet.has(p));
437
+ const droppedRisks = prev.riskPaths.filter((p) => !curSet.has(p));
438
+ const d = {
439
+ baselineTs: prev.ts,
440
+ changed: false,
441
+ totalDelta: cur.summary.total - prev.summary.total,
442
+ provenDelta: cur.summary.proven - prev.summary.proven,
443
+ associatedDelta: cur.summary.associated - prev.summary.associated,
444
+ candidateDelta: cur.summary.candidate - prev.summary.candidate,
445
+ noneDelta: cur.summary.none - prev.summary.none,
446
+ newRisks,
447
+ droppedRisks,
448
+ generatedDelta: cur.generatedTotal - prev.generatedTotal
449
+ };
450
+ d.changed =
451
+ d.totalDelta !== 0 || d.provenDelta !== 0 || d.associatedDelta !== 0 || d.candidateDelta !== 0 ||
452
+ d.noneDelta !== 0 || d.generatedDelta !== 0 || newRisks.length > 0 || droppedRisks.length > 0;
453
+ return d;
454
+ }
455
+ export function reportBaselineOf(cur, ts) {
456
+ return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal };
457
+ }
458
+ const LANE_OF = {
459
+ MUTATION: { id: "graphql", label: "GraphQL" },
460
+ QUERY: { id: "graphql", label: "GraphQL" },
461
+ SUBSCRIPTION: { id: "graphql", label: "GraphQL" },
462
+ GET: { id: "http", label: "HTTP" },
463
+ POST: { id: "http", label: "HTTP" },
464
+ PUT: { id: "http", label: "HTTP" },
465
+ PATCH: { id: "http", label: "HTTP" },
466
+ DELETE: { id: "http", label: "HTTP" },
467
+ JOB: { id: "job", label: "Jobs" }
468
+ };
469
+ function ownerOfSig(sig) {
470
+ const base = sig.includes("#") ? sig.slice(sig.indexOf("#") + 1) : sig;
471
+ return base.includes(".") ? base.slice(0, base.indexOf(".")) : base;
472
+ }
473
+ export function buildSystemMapModel(data, maxServices = 12) {
474
+ // Pass 1: how many trigger flows does each owner appear in? Shared
475
+ // infrastructure (config drivers, exception handlers, caches) appears in
476
+ // nearly all of them — the LAST-step heuristic crowned exactly that plumbing
477
+ // on a full-scale repo. A flow's representative service is its most
478
+ // DISTINCTIVE owner: lowest global frequency, deepest step on ties, with
479
+ // near-ubiquitous owners eligible only when a flow touches nothing else.
480
+ const triggerFlows = data.flows.filter((f) => f.trigger && LANE_OF[(f.trigger.verb || "").toUpperCase()]);
481
+ const ownerFreq = new Map();
482
+ for (const f of triggerFlows) {
483
+ const seen = new Set();
484
+ for (const st of f.steps ?? []) {
485
+ const o = ownerOfSig(st.sig);
486
+ if (o && !seen.has(o)) {
487
+ seen.add(o);
488
+ ownerFreq.set(o, (ownerFreq.get(o) ?? 0) + 1);
489
+ }
490
+ }
491
+ }
492
+ const infraCutoff = Math.max(3, Math.floor(triggerFlows.length * 0.4));
493
+ const isSharedInfra = (o) => (ownerFreq.get(o) ?? 0) >= infraCutoff;
494
+ const laneFlows = new Map();
495
+ const svcFlows = new Map();
496
+ const edgeFlows = new Map();
497
+ const svcLaneFlows = new Map();
498
+ for (const f of triggerFlows) {
499
+ const lane = LANE_OF[(f.trigger.verb || "").toUpperCase()];
500
+ const steps = f.steps ?? [];
501
+ let svc = "";
502
+ let bestFreq = Infinity;
503
+ let bestDepth = -1;
504
+ for (let i = 0; i < steps.length; i++) {
505
+ const o = ownerOfSig(steps[i].sig);
506
+ if (!o || isSharedInfra(o))
507
+ continue;
508
+ const freq = ownerFreq.get(o) ?? 0;
509
+ if (freq < bestFreq || (freq === bestFreq && i > bestDepth)) {
510
+ svc = o;
511
+ bestFreq = freq;
512
+ bestDepth = i;
513
+ }
514
+ }
515
+ if (!svc) {
516
+ // Flow touches only shared infra — fall back to its deepest step.
517
+ const last = steps.length ? steps[steps.length - 1] : undefined;
518
+ svc = last ? ownerOfSig(last.sig) : "";
519
+ }
520
+ if (!svc)
521
+ continue;
522
+ laneFlows.set(lane.id, (laneFlows.get(lane.id) ?? 0) + 1);
523
+ svcFlows.set(svc, (svcFlows.get(svc) ?? 0) + 1);
524
+ const key = lane.id + "\u0000" + svc;
525
+ edgeFlows.set(key, (edgeFlows.get(key) ?? 0) + 1);
526
+ const perLane = svcLaneFlows.get(svc) ?? new Map();
527
+ perLane.set(lane.id, (perLane.get(lane.id) ?? 0) + 1);
528
+ svcLaneFlows.set(svc, perLane);
529
+ }
530
+ const laneRank = (svc) => {
531
+ const perLane = svcLaneFlows.get(svc);
532
+ if (!perLane)
533
+ return 99;
534
+ let best = "";
535
+ let bestN = -1;
536
+ for (const [l, n] of perLane)
537
+ if (n > bestN) {
538
+ best = l;
539
+ bestN = n;
540
+ }
541
+ return ["graphql", "http", "job"].indexOf(best);
542
+ };
543
+ const byTraffic = [...svcFlows.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
544
+ const chosen = new Set();
545
+ // 1) Every lane is guaranteed its top 2 services — a lane with 56 flows must
546
+ // never render edge-less just because its traffic is spread thin.
547
+ for (const laneId of ["graphql", "http", "job"]) {
548
+ let taken = 0;
549
+ for (const [svc] of byTraffic) {
550
+ if (taken >= 2)
551
+ break;
552
+ if (laneRank(svc) === ["graphql", "http", "job"].indexOf(laneId) && !chosen.has(svc)) {
553
+ chosen.add(svc);
554
+ taken++;
555
+ }
556
+ }
557
+ }
558
+ // 2) Top-risk owners with real flow traffic join the map (up to 3) so the
559
+ // red rings — the whole point of the overlay — survive full scale.
560
+ let riskAdds = 0;
561
+ for (const r of data.risks) {
562
+ if (riskAdds >= 3)
563
+ break;
564
+ const owner = ownerOfSig(r.path);
565
+ if ((svcFlows.get(owner) ?? 0) > 0 && !chosen.has(owner)) {
566
+ chosen.add(owner);
567
+ riskAdds++;
568
+ }
569
+ }
570
+ // 3) Fill remaining slots by global traffic.
571
+ for (const [svc] of byTraffic) {
572
+ if (chosen.size >= maxServices + riskAdds)
573
+ break;
574
+ chosen.add(svc);
575
+ }
576
+ const top = byTraffic
577
+ .filter(([svc]) => chosen.has(svc))
578
+ // Group vertically under each service's dominant lane so edges flow in
579
+ // bands instead of crossing the whole canvas.
580
+ .sort((a, b) => laneRank(a[0]) - laneRank(b[0]) || b[1] - a[1] || a[0].localeCompare(b[0]));
581
+ const topSet = new Set(top.map(([k]) => k));
582
+ const tiersBySvc = new Map();
583
+ for (const b of data.behaviors) {
584
+ const svc = ownerOfSig(b.sig);
585
+ if (!topSet.has(svc))
586
+ continue;
587
+ const t = tiersBySvc.get(svc) ?? { proven: 0, assoc: 0, candidate: 0, none: 0 };
588
+ if (b.tier === "proven")
589
+ t.proven++;
590
+ else if (b.tier === "assoc")
591
+ t.assoc++;
592
+ else if (b.tier === "candidate")
593
+ t.candidate++;
594
+ else
595
+ t.none++;
596
+ tiersBySvc.set(svc, t);
597
+ }
598
+ const riskBySvc = new Map();
599
+ for (const r of data.risks) {
600
+ const svc = ownerOfSig(r.path);
601
+ if (!topSet.has(svc))
602
+ continue;
603
+ const e = riskBySvc.get(svc) ?? { ranks: [], critical: false };
604
+ e.ranks.push(r.rank);
605
+ if (r.tags.some(([label, kind]) => kind === "risk" && label.startsWith("critical")))
606
+ e.critical = true;
607
+ riskBySvc.set(svc, e);
608
+ }
609
+ const laneOrder = ["graphql", "http", "job"];
610
+ // Conservation: edges drawn per lane must sum to the lane's stated count.
611
+ // Everything below the cut aggregates into one dashed "+N more services"
612
+ // node per lane — 51 job flows must never silently vanish.
613
+ const shownEdges = [...edgeFlows.entries()]
614
+ .map(([k, flows]) => ({ lane: k.split("\u0000")[0], service: k.split("\u0000")[1], flows }))
615
+ .filter((e) => topSet.has(e.service));
616
+ const shownPerLane = new Map();
617
+ for (const e of shownEdges)
618
+ shownPerLane.set(e.lane, (shownPerLane.get(e.lane) ?? 0) + e.flows);
619
+ const restNodes = [];
620
+ const restEdges = [];
621
+ for (const laneId of laneOrder) {
622
+ const total = laneFlows.get(laneId) ?? 0;
623
+ const shown = shownPerLane.get(laneId) ?? 0;
624
+ if (total - shown <= 0)
625
+ continue;
626
+ const hiddenSvcs = new Set();
627
+ for (const [k, n] of edgeFlows) {
628
+ const [l, svc] = k.split("\u0000");
629
+ if (l === laneId && !topSet.has(svc) && n > 0)
630
+ hiddenSvcs.add(svc);
631
+ }
632
+ const restId = "rest:" + laneId;
633
+ restNodes.push({
634
+ id: restId,
635
+ label: "+" + hiddenSvcs.size + " more services",
636
+ flows: total - shown,
637
+ tiers: { proven: 0, assoc: 0, candidate: 0, none: 0 },
638
+ riskRanks: [],
639
+ critical: false,
640
+ rest: { services: hiddenSvcs.size }
641
+ });
642
+ restEdges.push({ lane: laneId, service: restId, flows: total - shown });
643
+ }
644
+ return {
645
+ lanes: laneOrder
646
+ .filter((id) => laneFlows.has(id))
647
+ .map((id) => ({ id, label: id === "graphql" ? "GraphQL" : id === "http" ? "HTTP" : "Jobs", flows: laneFlows.get(id) ?? 0 })),
648
+ services: [
649
+ ...top.map(([label, flows]) => ({
650
+ id: label,
651
+ label,
652
+ flows,
653
+ tiers: tiersBySvc.get(label) ?? { proven: 0, assoc: 0, candidate: 0, none: 0 },
654
+ riskRanks: (riskBySvc.get(label)?.ranks ?? []).sort((a, b) => a - b),
655
+ critical: riskBySvc.get(label)?.critical ?? false
656
+ })),
657
+ ...restNodes
658
+ ],
659
+ edges: [...shownEdges, ...restEdges].sort((a, b) => a.lane.localeCompare(b.lane) || b.flows - a.flows || a.service.localeCompare(b.service))
660
+ };
661
+ }
662
+ /** Deterministic per-risk APPLICABLE concern categories — derived from graph
663
+ * facts, never from whether tests exist. Covered = what attached tests
664
+ * address (via bucket). Locked pills = applicable − covered: the platform
665
+ * generates those categories; nothing pretends hidden tests already exist. */
666
+ const CONCERN_ORDER = ["contract", "authorization_safety", "boundary_limits", "integration_flow", "state_lifecycle", "failure_recovery", "data_integrity", "concurrency_ordering"];
667
+ function riskApplicableConcerns(risk, verb) {
668
+ const out = new Set(["contract"]); // an observable behavior always has a contract to verify
669
+ const sens = risk.data_sensitivity ?? 1;
670
+ if (sens >= 9)
671
+ out.add("authorization_safety");
672
+ if (sens >= 6)
673
+ out.add("data_integrity");
674
+ if (risk.entry_point || verb !== "BEHAVIOR")
675
+ out.add("boundary_limits"); // external inputs cross here
676
+ if ((risk.flow_position ?? 0) >= 3 || (risk.fan_out ?? 0) >= 1)
677
+ out.add("integration_flow");
678
+ if ((risk.fan_out ?? 0) >= 1 && risk.git_churn > 0)
679
+ out.add("state_lifecycle");
680
+ if ((risk.fan_out ?? 0) >= 2)
681
+ out.add("failure_recovery"); // downstream dependencies can fail
682
+ if (/job|queue|cron|stream|lock|worker/i.test(risk.title + " " + risk.file))
683
+ out.add("concurrency_ordering");
684
+ return CONCERN_ORDER.filter((c) => out.has(c));
685
+ }
686
+ const BUCKET_TO_CONCERN = {
687
+ happy_path: "contract",
688
+ validation_error: "contract",
689
+ edge_case: "boundary_limits",
690
+ regression: "failure_recovery",
691
+ security_privacy: "authorization_safety",
692
+ integration_flow: "integration_flow"
693
+ };
694
+ /** State-aware next step — varies by attached tests, trigger kind, signal, and
695
+ * sensitivity, so no two cards read identically for different reasons. */
696
+ function riskTodo(risk, verb, path, generatedTests) {
697
+ if (generatedTests.length && generatedTests.every((t) => t.runnable !== false)) {
698
+ return "Run the generated test below in your repo; follow its prove handoff so a mutation failure can mint Dynamically Proven.";
699
+ }
700
+ if (generatedTests.length) {
701
+ return "Install this repo's dependencies / set up the test runner, then re-run `opro start` to turn the English intents below into runnable tests.";
702
+ }
703
+ const call = verb !== "BEHAVIOR"
704
+ ? `issues ${verb} ${path}`
705
+ : risk.entry_point
706
+ ? `invokes ${risk.title} through its entry point`
707
+ : `calls ${risk.title} directly`;
708
+ const sens = (risk.data_sensitivity ?? 1) >= 9
709
+ ? " Include a negative case: invalid or expired credentials must fail closed."
710
+ : (risk.data_sensitivity ?? 1) >= 7
711
+ ? " Include a failure case: a rejected transaction must leave no partial state."
712
+ : "";
713
+ if (risk.integration_signal === "candidate") {
714
+ return `A similarly named test exists but nothing links it. Write a test that imports and ${call}, asserting the observable outcome — that upgrades this from unconfirmed candidate to a hard link.${sens}`;
715
+ }
716
+ return `No test signal exists. Start with one integration test that ${call} and asserts the observable outcome.${sens}`;
717
+ }
370
718
  function riskRows(risks, graph) {
719
+ const maxRiskScore = risks.reduce((m, r) => Math.max(m, r.risk_score), 0);
371
720
  const riskIds = new Set(risks.map((r) => r.id));
372
721
  const firstRowForFile = new Map();
373
722
  for (const r of risks)
@@ -376,28 +725,36 @@ function riskRows(risks, graph) {
376
725
  return risks.map((risk, idx) => {
377
726
  const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
378
727
  const tags = [];
379
- const bucket = riskBucket(risk.risk_score);
728
+ const bucket = riskBucket(risk.risk_score, maxRiskScore);
380
729
  if (bucket)
381
730
  tags.push([`${bucket} risk`, "risk"]);
382
- tags.push([`${risk.incoming_refs} incoming refs`, "info"]);
731
+ tags.push([`${fmtRefs(risk.incoming_refs)} incoming refs`, "info"]);
383
732
  if (risk.entry_point)
384
733
  tags.push(["Entry point", "entry"]);
385
734
  return {
386
735
  rank: idx + 1,
387
736
  ...(() => {
388
737
  const generatedTests = riskGeneratedTests(graph, risk, riskIds, firstRowForFile.get(risk.file) === risk.id);
738
+ const verb = methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR";
739
+ const path = methodMatch?.[2] ?? risk.title;
740
+ const coveredCategories = [...new Set([
741
+ ...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
742
+ // An integration/api/e2e-layer test covers the integration_flow
743
+ // category by construction — the chip and the pill must agree.
744
+ ...generatedTests.map((t) => (t.concern === "integration" || t.concern === "api" || t.concern === "e2e" ? "integration_flow" : undefined))
745
+ ].filter((c) => Boolean(c)))];
389
746
  return {
390
747
  generatedTests,
391
- // Honest category strip: only the concerns of REAL attached tests —
392
- // every pill renders as shown ("n of n"), none fabricated as locked.
393
- applicableCategories: [...new Set(generatedTests.map((t) => t.concern).filter((c) => Boolean(c)))]
748
+ applicableCategories: riskApplicableConcerns(risk, verb),
749
+ coveredCategories,
750
+ verb,
751
+ path,
752
+ todo: riskTodo(risk, verb, path, generatedTests)
394
753
  };
395
754
  })(),
396
- verb: methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR",
397
- path: methodMatch?.[2] ?? risk.title,
755
+ context: riskContext(risk),
398
756
  desc: risk.reasons.join(" · "),
399
- tags,
400
- todo: "Write an integration or behavior test that calls this behavior and asserts the observable outcome."
757
+ tags
401
758
  };
402
759
  });
403
760
  }
@@ -416,6 +773,8 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
416
773
  const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20 });
417
774
  const lists = behaviorLists(rows, flowIds);
418
775
  const risks = riskRows(riskGaps, graph);
776
+ const sortedBehaviors = [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b));
777
+ const flowRows = flows(graph, rows, riskGaps);
419
778
  return {
420
779
  repo: path.basename(repoRoot || graph.workspace.name || "repo"),
421
780
  scanned: (graph.updated_at || graph.created_at || new Date(0).toISOString()).slice(0, 10),
@@ -427,11 +786,22 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
427
786
  scan: scanBlock(graph, rows),
428
787
  behaviorGroups: lists.behaviorGroups,
429
788
  // Proven first so the strongest evidence leads the grid (stable within tiers).
430
- behaviors: [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b)),
431
- flows: flows(graph, rows, riskGaps),
789
+ behaviors: sortedBehaviors,
790
+ flows: flowRows,
432
791
  candidateFlows: candidateFlows(graph),
433
792
  risks,
434
793
  zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
794
+ mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
795
+ viewMeta: {
796
+ // Every denominator behavior is scored; the risks tab surfaces the top N.
797
+ risks: { shown: risks.length, scored: summary.total },
798
+ flows: {
799
+ shown: graph.analysis?.flows?.total_flows ?? 0,
800
+ prunedByCaps: (graph.analysis?.flows?.dropped?.max_depth ?? 0) +
801
+ (graph.analysis?.flows?.dropped?.max_flows_per_entry ?? 0) +
802
+ (graph.analysis?.flows?.dropped?.global_cap ?? 0)
803
+ }
804
+ },
435
805
  generatedTotal: graph.generated_tests?.length ?? 0,
436
806
  shownCount: risks.reduce((acc, r) => acc + r.generatedTests.length, 0)
437
807
  };