@orangepro/orangepro-mcp 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -12
- package/dist/local/analyze/analyzer.js +61 -2
- package/dist/local/analyze/behaviorContracts.js +78 -5
- package/dist/local/analyze/parseCache.js +17 -3
- package/dist/local/analyze/treeSitter/engine.js +81 -4
- package/dist/local/autoProve.js +39 -21
- package/dist/local/cli.js +13 -3
- package/dist/local/enrich/markdown.js +11 -0
- package/dist/local/flows/flowWalker.js +11 -6
- package/dist/local/generate/generator.js +54 -3
- package/dist/local/generate/promptV5.js +12 -1
- package/dist/local/graph/ontology.js +3 -1
- package/dist/local/operations.js +61 -10
- package/dist/local/proofDoctor.js +28 -3
- package/dist/local/rtm.js +30 -26
- package/dist/local/score/risk.js +128 -37
- package/dist/local/util/walk.js +5 -4
- package/dist/local/viz/behaviorReportData.js +393 -23
- package/dist/local/viz/behaviorReportHtml.js +225 -10
- package/dist/local/viz/coverageReveal.js +29 -0
- package/dist/local/viz/payload.js +12 -5
- package/dist/local/workspace.js +25 -2
- package/package.json +7 -2
- package/scripts/spikes/go-dynamic-proof-spike.mjs +17 -13
- package/scripts/spikes/go-mutate.go +60 -21
- package/scripts/spikes/java-dynamic-proof-spike.mjs +6 -4
- package/scripts/spikes/java-mutate.mjs +3 -2
|
@@ -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"
|
|
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
|
-
|
|
251
|
-
|
|
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 (
|
|
271
|
+
if (rel >= 0.5)
|
|
254
272
|
return "high";
|
|
255
|
-
if (
|
|
273
|
+
if (rel >= 0.25)
|
|
256
274
|
return "medium";
|
|
257
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
748
|
+
applicableCategories: riskApplicableConcerns(risk, verb),
|
|
749
|
+
coveredCategories,
|
|
750
|
+
verb,
|
|
751
|
+
path,
|
|
752
|
+
todo: riskTodo(risk, verb, path, generatedTests)
|
|
394
753
|
};
|
|
395
754
|
})(),
|
|
396
|
-
|
|
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:
|
|
431
|
-
flows:
|
|
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
|
};
|