@orangepro/orangepro-mcp 0.2.35 → 0.2.37
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.
|
@@ -475,6 +475,18 @@ export function analyzeRepo(root, opts = {}) {
|
|
|
475
475
|
// Emitted CodeSymbol names per file — the call graph resolves callers/callees
|
|
476
476
|
// ONLY to symbols that actually became nodes (the "known symbol" invariant).
|
|
477
477
|
const symbolsByFile = new Map();
|
|
478
|
+
// Retained callee NAMES (no node, no edge) for calls the resolver cannot anchor —
|
|
479
|
+
// shared by the TS/JS loop and the tree-sitter loop; persisted as external_callees.
|
|
480
|
+
const externalCalleesByCaller = new Map();
|
|
481
|
+
const recordExternalCallee = (callerId, name) => {
|
|
482
|
+
let set = externalCalleesByCaller.get(callerId);
|
|
483
|
+
if (!set) {
|
|
484
|
+
set = new Set();
|
|
485
|
+
externalCalleesByCaller.set(callerId, set);
|
|
486
|
+
}
|
|
487
|
+
if (set.size < 32)
|
|
488
|
+
set.add(name);
|
|
489
|
+
};
|
|
478
490
|
// Raw (caller, callee) call pairs per TS/JS code file, resolved after the
|
|
479
491
|
// import graph is built (cross-file calls need its bindings + targets).
|
|
480
492
|
const rawCallsByFile = new Map();
|
|
@@ -1312,6 +1324,8 @@ export function analyzeRepo(root, opts = {}) {
|
|
|
1312
1324
|
}
|
|
1313
1325
|
}
|
|
1314
1326
|
// A non-imported (local/param) qualifier is NOT anchored — no edge.
|
|
1327
|
+
if (!ns && !qImport)
|
|
1328
|
+
recordExternalCallee(callerId, `${c.qualifier}.${c.callee}`); // round two: retain by name
|
|
1315
1329
|
// Retain the callee NAME as a fact on the caller (Fix B): `t.adminClient.
|
|
1316
1330
|
// DeleteWorkflowExecution` is invisible as an edge (external interface) but
|
|
1317
1331
|
// is exactly the kind of sink risk scoring must be able to see. Names only —
|
|
@@ -1319,6 +1333,10 @@ export function analyzeRepo(root, opts = {}) {
|
|
|
1319
1333
|
}
|
|
1320
1334
|
}
|
|
1321
1335
|
else if (c.via === "injected" && c.injectedType) {
|
|
1336
|
+
// Retain `this.<field>.<callee>` by name regardless of resolution: names are
|
|
1337
|
+
// facts, not evidence, and consequence signals need the callee even when the
|
|
1338
|
+
// injected type never resolves (round two — TS parity with Go).
|
|
1339
|
+
recordExternalCallee(callerId, `this.${c.qualifier ?? c.injectedType}.${c.callee}`);
|
|
1322
1340
|
const typeBinding = typeImports?.get(c.injectedType) ?? imports?.get(c.injectedType);
|
|
1323
1341
|
if (typeBinding) {
|
|
1324
1342
|
const targetMember = resolveInjectedMember(typeBinding, c.callee);
|
|
@@ -1859,16 +1877,6 @@ export function analyzeRepo(root, opts = {}) {
|
|
|
1859
1877
|
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
1860
1878
|
return goPkgMethods.get(dir)?.get(member);
|
|
1861
1879
|
};
|
|
1862
|
-
const externalCalleesByCaller = new Map();
|
|
1863
|
-
const recordExternalCallee = (callerId, name) => {
|
|
1864
|
-
let set = externalCalleesByCaller.get(callerId);
|
|
1865
|
-
if (!set) {
|
|
1866
|
-
set = new Set();
|
|
1867
|
-
externalCalleesByCaller.set(callerId, set);
|
|
1868
|
-
}
|
|
1869
|
-
if (set.size < 32)
|
|
1870
|
-
set.add(name);
|
|
1871
|
-
};
|
|
1872
1880
|
for (const [rel, { language, structure }] of nonTsStructureByFile) {
|
|
1873
1881
|
const localSyms = symbolsByFile.get(rel);
|
|
1874
1882
|
if (!localSyms)
|
package/dist/local/score/risk.js
CHANGED
|
@@ -315,8 +315,15 @@ const NEW_CODE_SECONDS = NEW_CODE_DAYS * 24 * 60 * 60;
|
|
|
315
315
|
/** Calls that make a defect irreversible: deletes, drops, purges. Matched on the
|
|
316
316
|
* LAST segment of a call name, so `t.adminClient.DeleteWorkflowExecution` and a
|
|
317
317
|
* local `purgeAll` both count; `deleteButtonLabel` (no call) does not. */
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
// `truncate` dropped (time.Truncate is common; DB truncation is rare in app code);
|
|
319
|
+
// `remove` kept but not for listener/handler/attribute/child tails — those detach,
|
|
320
|
+
// they don't destroy data.
|
|
321
|
+
const DESTRUCTIVE_CALL_RE = /^(?:delete(?!d)|purge|drop|forcedelete|destroy)[A-Za-z0-9_]*$/i;
|
|
322
|
+
// `remove*` is only a data sink through a persistence-shaped field; in-memory
|
|
323
|
+
// removals (`pollers.Remove`, `RemoveSpeculativeWorkflowTaskTimeout`) are not.
|
|
324
|
+
const REMOVE_CALL_RE = /^remove(?![A-Za-z0-9_]*(?:listener|handler|observer|callback|attribute|attr|class|child|style|hook|timeout|timer)$)[A-Za-z0-9_]*$/i;
|
|
325
|
+
const PERSISTENCE_FIELD_RE = /(store|client|db|repo|repository|persistence|manager|queue|bucket|index|table|storage|dao)/i;
|
|
326
|
+
const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(run|execute|handle|process|tick|scan)$/i;
|
|
320
327
|
const SCHEDULED_ENTRY_PATH_RE = /(^|\/)(jobs?|workers?|scanners?|scavengers?|cron|schedulers?|processors?|consumers?|reconcil\w*)(\/|$)/i;
|
|
321
328
|
/** A time- or queue-triggered entry: nothing calls it synchronously, nobody waits
|
|
322
329
|
* for a response, so a failure is far less likely to be NOTICED. Graph facts only. */
|
|
@@ -378,7 +385,9 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
378
385
|
// Fix D: ranking hygiene. Test-support code, bare constants/variables, and trivial
|
|
379
386
|
// accessors stay in the behavior DENOMINATOR but never compete for a risk slot.
|
|
380
387
|
const TEST_SUPPORT_PATH_RE = /(^|\/)(testing|testutils?|testhelpers?|fixtures?|mocks?|fakes?)(\/|$)/i;
|
|
381
|
-
const
|
|
388
|
+
const cfgEarly = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.classification;
|
|
389
|
+
const extraTestSupportRef = cfgEarly.test_support_paths.map(globToRegExp);
|
|
390
|
+
const rankExcludeRef = cfgEarly.rank_exclude_paths.map(globToRegExp);
|
|
382
391
|
const ACCESSOR_RE = /(^|\.)(Get|Set|Is|Has)[A-Z][A-Za-z0-9]*$/;
|
|
383
392
|
const rankEligible = (n) => {
|
|
384
393
|
const props = (n.properties ?? {});
|
|
@@ -395,6 +404,8 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
395
404
|
const span = (props.end_line ?? 0) - (props.start_line ?? 0);
|
|
396
405
|
if (ACCESSOR_RE.test(n.title || "") && span <= 3)
|
|
397
406
|
return false;
|
|
407
|
+
if (rankExcludeRef.some((re) => re.test(symbolFile(n))))
|
|
408
|
+
return false;
|
|
398
409
|
return true;
|
|
399
410
|
};
|
|
400
411
|
const suppressedRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.overrides.filter((o) => o.action === "suppress");
|
|
@@ -483,29 +494,42 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
483
494
|
// A sink is a destructive call on an EXTERNAL surface — a persistence store, an
|
|
484
495
|
// admin/service client, a database handle. In-repo methods named `delete` are
|
|
485
496
|
// not sinks by name alone (CHASM's `Node.delete` is a tree op, not a data loss).
|
|
486
|
-
|
|
487
|
-
|
|
497
|
+
// A retained callee is by construction a call through a receiver FIELD to code
|
|
498
|
+
// the graph could not resolve — i.e. an external surface. That structural fact is
|
|
499
|
+
// the test; the qualifier's NAME is not (round one's store/client/db vocabulary was
|
|
500
|
+
// Temporal-shaped and hid inngest's `w.q.DeleteOldQueueSnapshots`).
|
|
501
|
+
const sinkCallee = (id) => {
|
|
488
502
|
const n = nodeById.get(id);
|
|
489
503
|
if (!n)
|
|
490
|
-
return
|
|
504
|
+
return undefined;
|
|
491
505
|
const ext = n.properties?.external_callees ?? [];
|
|
492
|
-
|
|
506
|
+
// A receiver FIELD path is `x.field.Method` — no call parentheses before the last
|
|
507
|
+
// segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
|
|
508
|
+
const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
|
|
509
|
+
const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
|
|
510
|
+
return ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
|
|
511
|
+
(REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
|
|
512
|
+
extraSinks.some((re) => re.test(lastSeg(c)))));
|
|
493
513
|
};
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
514
|
+
const isSink = (id) => sinkCallee(id) !== undefined;
|
|
515
|
+
const reachedSinkFrom = (id, depth, seen) => {
|
|
516
|
+
const own = sinkCallee(id);
|
|
517
|
+
if (own)
|
|
518
|
+
return own;
|
|
497
519
|
if (depth === 0)
|
|
498
|
-
return
|
|
520
|
+
return undefined;
|
|
499
521
|
for (const t of fanOutTargets.get(id) ?? []) {
|
|
500
522
|
if (seen.has(t))
|
|
501
523
|
continue;
|
|
502
524
|
seen.add(t);
|
|
503
|
-
|
|
504
|
-
|
|
525
|
+
const hit = reachedSinkFrom(t, depth - 1, seen);
|
|
526
|
+
if (hit)
|
|
527
|
+
return hit;
|
|
505
528
|
}
|
|
506
|
-
return
|
|
529
|
+
return undefined;
|
|
507
530
|
};
|
|
508
|
-
const
|
|
531
|
+
const sinkReached = new Map(symbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
|
|
532
|
+
const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
|
|
509
533
|
const depthCtx = buildFlowDepthContext(graph);
|
|
510
534
|
const staticLinked = staticTestLinkedIds(graph, symbolIds);
|
|
511
535
|
const candidateLinked = candidateSignalIds(graph, symbolIds);
|
|
@@ -605,6 +629,9 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
605
629
|
reasons.push(`config override (${override.action}): ${override.reason}`);
|
|
606
630
|
return {
|
|
607
631
|
...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
|
|
632
|
+
...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
|
|
633
|
+
...(rawScores[idx].scheduledEntry ? { scheduled_entry: true } : {}),
|
|
634
|
+
detection_tier: detectionTier,
|
|
608
635
|
id: s.external_id,
|
|
609
636
|
title: s.title || s.external_id,
|
|
610
637
|
file,
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
11
12
|
export const DEFAULT_RISK_CONFIG = {
|
|
12
|
-
classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [] },
|
|
13
|
+
classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [], rank_exclude_paths: [] },
|
|
13
14
|
tuning: { irreversibility_floor: true, silence_multiplier: true },
|
|
14
15
|
overrides: []
|
|
15
16
|
};
|
|
@@ -21,40 +22,50 @@ export function globToRegExp(glob) {
|
|
|
21
22
|
const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
|
|
22
23
|
return new RegExp(`^${esc}$`);
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
cfg.
|
|
39
|
-
if (typeof tun.silence_multiplier === "boolean")
|
|
40
|
-
cfg.tuning.silence_multiplier = tun.silence_multiplier;
|
|
41
|
-
for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
|
|
42
|
-
const ov = o;
|
|
43
|
-
if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
|
|
44
|
-
warnings.push(`config: override ignored (needs symbol + action): ${JSON.stringify(o).slice(0, 80)}`);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
if (typeof ov.reason !== "string" || ov.reason.trim().length < 8) {
|
|
48
|
-
warnings.push(`config: override for ${ov.symbol} ignored — a reason (≥8 chars) is required so it can be shown on the report.`);
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
cfg.overrides.push({ symbol: ov.symbol, action: ov.action, sensitivity: ov.sensitivity, reason: ov.reason.trim() });
|
|
52
|
-
}
|
|
25
|
+
/** User-level defaults: ~/.orangepro/config.json (override with ORANGEPRO_USER_CONFIG for tests/CI).
|
|
26
|
+
* Applied FIRST; the analyzed repo's .orangepro/config.json wins on every key it sets.
|
|
27
|
+
* The hash covers the merged result, so provenance still tells the truth. */
|
|
28
|
+
export function userConfigPath() {
|
|
29
|
+
return process.env.ORANGEPRO_USER_CONFIG ?? join(homedir(), ".orangepro", "config.json");
|
|
30
|
+
}
|
|
31
|
+
function applyFile(cfg, file, warnings, label) {
|
|
32
|
+
if (!existsSync(file))
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
36
|
+
const cls = (raw.classification ?? {});
|
|
37
|
+
for (const k of ["test_support_paths", "scheduled_entry_paths", "destructive_sinks", "sensitivity_ignore", "rank_exclude_paths"]) {
|
|
38
|
+
if (Array.isArray(cls[k]))
|
|
39
|
+
cfg.classification[k] = asStringArray(cls[k]);
|
|
53
40
|
}
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
const tun = (raw.tuning ?? {});
|
|
42
|
+
if (typeof tun.irreversibility_floor === "boolean")
|
|
43
|
+
cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
|
|
44
|
+
if (typeof tun.silence_multiplier === "boolean")
|
|
45
|
+
cfg.tuning.silence_multiplier = tun.silence_multiplier;
|
|
46
|
+
for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
|
|
47
|
+
const ov = o;
|
|
48
|
+
if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
|
|
49
|
+
warnings.push(`config (${label}): override ignored (needs symbol + action): ${JSON.stringify(o).slice(0, 80)}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (typeof ov.reason !== "string" || ov.reason.trim().length < 8) {
|
|
53
|
+
warnings.push(`config (${label}): override for ${ov.symbol} ignored — a reason (≥8 chars) is required so it can be shown on the report.`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
cfg.overrides.push({ symbol: ov.symbol, action: ov.action, sensitivity: ov.sensitivity, reason: ov.reason.trim() });
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
warnings.push(`config (${label}): unreadable for risk settings (${err.message}); skipped.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function loadRiskConfig(repoRoot) {
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const cfg = JSON.parse(JSON.stringify(DEFAULT_RISK_CONFIG));
|
|
66
|
+
applyFile(cfg, userConfigPath(), warnings, "user defaults");
|
|
67
|
+
if (repoRoot)
|
|
68
|
+
applyFile(cfg, join(repoRoot, ".orangepro", "config.json"), warnings, "repo");
|
|
58
69
|
const canonical = JSON.stringify(cfg, Object.keys(cfg).sort());
|
|
59
70
|
const hash = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 12);
|
|
60
71
|
void canonical;
|
|
@@ -2,7 +2,7 @@ import { loadRiskConfig } from "../score/riskConfig.js";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { buildRtm } from "../rtm.js";
|
|
5
|
-
import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps } from "../score/risk.js";
|
|
5
|
+
import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps } from "../score/risk.js";
|
|
6
6
|
import { ORANGEPRO_VERSION } from "../version.js";
|
|
7
7
|
import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
|
|
8
8
|
import { classifyGeneratedDraftBlocker } from "../generate/draftGuidance.js";
|
|
@@ -451,21 +451,49 @@ function riskContext(risk) {
|
|
|
451
451
|
: (risk.data_sensitivity ?? 1) >= 3 ? "notification/webhook"
|
|
452
452
|
: "";
|
|
453
453
|
const pos = (risk.flow_position ?? 0) >= 5
|
|
454
|
-
? "
|
|
454
|
+
? "entry point"
|
|
455
455
|
: (risk.flow_position ?? 0) >= 3
|
|
456
|
-
? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from
|
|
456
|
+
? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from an entry point`
|
|
457
457
|
: "deep in the call graph";
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
458
|
+
const sink = risk.sink_callee;
|
|
459
|
+
const scheduled = risk.scheduled_entry === true;
|
|
460
|
+
const churnKnown = risk.churn_available !== false;
|
|
461
|
+
const churn = churnKnown
|
|
462
|
+
? (risk.git_churn > 0 ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days` : "unchanged in 180 days")
|
|
463
|
+
: "change history unavailable";
|
|
464
|
+
const tier = risk.detection_tier ?? "";
|
|
465
|
+
const evidence = tier === "candidate"
|
|
466
|
+
? "no test links here (a similarly-named test exists but never calls it)"
|
|
467
|
+
: tier === "associated" ? "a test calls it but nothing proves it fails when broken" : "no test links here";
|
|
468
|
+
const sinkShort = sink ? sink.split(".").pop() ?? sink : "";
|
|
469
|
+
// Line 1 — the consequence, in plain English, from the signals only.
|
|
470
|
+
const lead = sink && scheduled
|
|
471
|
+
? `Runs on a schedule and can ${sinkShort.toLowerCase().startsWith("purge") ? "purge" : "delete"} data — nothing proves it does the right thing.`
|
|
472
|
+
: sink
|
|
473
|
+
? `Can ${sinkShort.toLowerCase().startsWith("purge") ? "purge" : "delete"} data and nothing proves it works.`
|
|
474
|
+
: scheduled
|
|
475
|
+
? "Runs on a schedule with no proof — a failure here surfaces nowhere."
|
|
476
|
+
: sens
|
|
477
|
+
? `Sits on ${sens} paths, changes, and nothing proves it.`
|
|
478
|
+
: "Reachable and changing, with nothing proving it.";
|
|
479
|
+
// Line 2 — what the graph saw. Facts only: names, counts, tiers.
|
|
480
|
+
const seen = [
|
|
481
|
+
pos + (sens ? ` on ${sens} paths` : ""),
|
|
482
|
+
...(sink ? [`reaches \`${sink}\` within two calls`] : []),
|
|
483
|
+
...(scheduled ? ["scheduled / queue-triggered"] : []),
|
|
484
|
+
`${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}`,
|
|
485
|
+
churn,
|
|
486
|
+
evidence
|
|
468
487
|
];
|
|
488
|
+
// Line 3 — what would close it: a test SHAPE, never a claim that one exists.
|
|
489
|
+
const close = sink
|
|
490
|
+
? `one test that drives the path to \`${sinkShort}\` and fails when the guard before it is broken.`
|
|
491
|
+
: scheduled
|
|
492
|
+
? "one test that runs this entry against a mutated dependency and fails."
|
|
493
|
+
: "one test that exercises this behavior and fails when it is mutated.";
|
|
494
|
+
const scoreWords = `ORS ${risk.risk_score} — ${(risk.probability ?? 0) >= 6 ? "changes often" : (risk.probability ?? 0) >= 3 ? "changes some" : "stable"} (${risk.probability ?? "?"}) × ${sink ? "irreversible" : (risk.impact ?? 0) >= 6 ? "high blast radius" : "moderate impact"} (${risk.impact ?? "?"}) × ${(risk.detection_difficulty ?? 0) >= 9 ? "unproven and silent" : "unproven"} (${risk.detection_difficulty ?? "?"})`;
|
|
495
|
+
const overrides = (risk.reasons ?? []).filter((r) => r.startsWith("config override"));
|
|
496
|
+
const parts = [lead, `Seen: ${seen.join(" · ")}.`, `Would close it: ${close}`, ...overrides.map((o) => `${o[0].toUpperCase()}${o.slice(1)}.`), scoreWords + "."];
|
|
469
497
|
return parts.join(" ");
|
|
470
498
|
}
|
|
471
499
|
/** Pure delta between a persisted baseline and the current report data.
|
|
@@ -898,6 +926,18 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
|
|
|
898
926
|
.filter((row) => row.evidence_tier === "proven" && Boolean(row.code_symbol))
|
|
899
927
|
.map((row) => row.code_symbol));
|
|
900
928
|
const riskGaps = rankPriorityGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, provenIds });
|
|
929
|
+
// Two worklists from ONE ranking, no new weights. A multiplicative P×I×D top-N
|
|
930
|
+
// cannot hold "changing fast, unproven" and "irreversible, stable, unproven" on
|
|
931
|
+
// the same page: with P=1 for stable code, one family always erases the other
|
|
932
|
+
// (verified three ways on Temporal). Same rows, two questions.
|
|
933
|
+
const wide = rankRiskGaps(graph, { repoRoot, limit: 200, provenIds });
|
|
934
|
+
const changeFrontier = [...wide]
|
|
935
|
+
.filter((r) => !r.sink_callee)
|
|
936
|
+
.sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0) || b.risk_score - a.risk_score || a.id.localeCompare(b.id))
|
|
937
|
+
.slice(0, 20)
|
|
938
|
+
.map((r) => ({ path: r.title, file: r.file, score: r.risk_score, probability: r.probability ?? 0 }));
|
|
939
|
+
const irreversible = wide.filter((r) => r.sink_callee).slice(0, 20)
|
|
940
|
+
.map((r) => ({ path: r.title, file: r.file, score: r.risk_score, sink: r.sink_callee ?? "" }));
|
|
901
941
|
const riskHealth = inspectRiskInputHealth(repoRoot);
|
|
902
942
|
const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
|
|
903
943
|
const provenance = {
|
|
@@ -935,6 +975,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
|
|
|
935
975
|
flows: flowRows,
|
|
936
976
|
candidateFlows: candidateFlows(graph),
|
|
937
977
|
risks,
|
|
978
|
+
worklists: { changeFrontier, irreversible },
|
|
938
979
|
zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
|
|
939
980
|
mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
|
|
940
981
|
viewMeta: {
|
|
@@ -169,6 +169,19 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
|
|
|
169
169
|
/* RISKS */
|
|
170
170
|
.risk-card{background:var(--s1);border:1px solid var(--bd);border-radius:9px;padding:14px 16px;margin-bottom:10px}
|
|
171
171
|
.risk-tools{display:flex;align-items:center;gap:8px;margin:0 0 12px;flex-wrap:wrap}
|
|
172
|
+
/* WORKLISTS — two views of one ranking */
|
|
173
|
+
.wl-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 0 14px}
|
|
174
|
+
.wl-card{background:var(--s1);border:1px solid var(--bd);border-radius:9px;padding:12px 14px}
|
|
175
|
+
.wl-title{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;margin:0 0 2px}
|
|
176
|
+
.wl-title.wl-change{color:var(--amber)} .wl-title.wl-irrev{color:var(--red)}
|
|
177
|
+
.wl-sub{font-size:11px;color:var(--muted);margin:0 0 8px;line-height:1.45}
|
|
178
|
+
.wl-row{display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--bd);font-family:var(--mono);font-size:11px;cursor:pointer}
|
|
179
|
+
.wl-row:last-child{border-bottom:0}
|
|
180
|
+
.wl-row:hover .wl-path{color:var(--ink)}
|
|
181
|
+
.wl-path{color:var(--ink2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
182
|
+
.wl-meta{color:var(--faint);flex-shrink:0;max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
183
|
+
.wl-empty{font-size:11.5px;color:var(--faint);padding:6px 0}
|
|
184
|
+
@media(max-width:800px){.wl-grid{grid-template-columns:1fr}}
|
|
172
185
|
.risk-filter{appearance:none;border:1px solid var(--bd);background:var(--s1);color:var(--muted);border-radius:20px;padding:5px 10px;font:inherit;font-size:12px;font-weight:600;cursor:pointer}
|
|
173
186
|
.risk-filter:hover{border-color:var(--bd2);color:var(--ink2)}
|
|
174
187
|
.risk-filter[aria-pressed="true"]{border-color:var(--orange);background:var(--obg);color:var(--orange)}
|
|
@@ -423,6 +436,7 @@ body[data-mode="expert"] .simple-only{display:none!important}
|
|
|
423
436
|
<p class="bridge expert-only">This is the <b>priority-gap worklist</b>, ranked by blast radius and test weakness. It is separate from the coverage-status cards above: <b>Reachable · no test signal</b> is one strict coverage bucket, not the number of priority gaps.</p>
|
|
424
437
|
<p class="bridge" id="risk-cap-note" style="font-size:12px;opacity:.75"></p>
|
|
425
438
|
<div class="risk-tools" id="risk-tools"></div>
|
|
439
|
+
<div id="worklists"></div>
|
|
426
440
|
<div id="risk-list"></div>
|
|
427
441
|
</section>
|
|
428
442
|
|
|
@@ -1008,6 +1022,24 @@ riskTools.addEventListener("click",e=>{
|
|
|
1008
1022
|
});
|
|
1009
1023
|
renderRiskFilters();
|
|
1010
1024
|
renderRisks();
|
|
1025
|
+
// ── Worklists: two views of ONE ranking. A single P×I×D top-20 cannot hold
|
|
1026
|
+
// "changing fast" and "irreversible but stable" together; both are shown here.
|
|
1027
|
+
(function(){
|
|
1028
|
+
const W=D.worklists, host=$("#worklists");
|
|
1029
|
+
if(!W||!host)return;
|
|
1030
|
+
const topPaths=new Set(D.risks.map(r=>r.path));
|
|
1031
|
+
const row=(path,meta)=>{
|
|
1032
|
+
const inTop=topPaths.has(path);
|
|
1033
|
+
return \`<div class="wl-row" data-path="\${esc(path).replace(/"/g,'"')}" title="\${inTop?'in the ranked list below — click to jump':'ranked, but below the top-20 cut'}"><span class="wl-path">\${esc(path)}</span><span class="wl-meta">\${esc(meta)}</span></div>\`;
|
|
1034
|
+
};
|
|
1035
|
+
const cf=(W.changeFrontier||[]).map(r=>row(r.path,"changes "+(r.probability>=7?"a lot":r.probability>=4?"often":"some"))).join("")||'<div class="wl-empty">nothing changing fast and unproven</div>';
|
|
1036
|
+
const ir=(W.irreversible||[]).map(r=>row(r.path,"→ "+(r.sink||"").split(".").pop())).join("")||'<div class="wl-empty">no unproven path reaches a delete</div>';
|
|
1037
|
+
host.innerHTML=\`<div class="wl-grid">
|
|
1038
|
+
<div class="wl-card"><p class="wl-title wl-change">Changing fast · unproven</p><p class="wl-sub">Where the code moves most with nothing proving it. The place a bug is most likely to have just arrived.</p>\${cf}</div>
|
|
1039
|
+
<div class="wl-card"><p class="wl-title wl-irrev">Can destroy data · unproven</p><p class="wl-sub">Paths that reach a delete or purge with nothing proving they do the right thing. Rarely changing — which is why nobody looks.</p>\${ir}</div>
|
|
1040
|
+
</div>\`;
|
|
1041
|
+
host.addEventListener("click",e=>{const r=e.target.closest("[data-path]");if(r&&topPaths.has(r.getAttribute("data-path")))scrollToRisk(r.getAttribute("data-path"));});
|
|
1042
|
+
})();
|
|
1011
1043
|
// toggle test expand
|
|
1012
1044
|
document.addEventListener('click',e=>{
|
|
1013
1045
|
const h=e.target.closest('.gen-test-head');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangepro/orangepro-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.37",
|
|
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",
|