@orangepro/orangepro-mcp 0.2.41 → 0.2.43

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.
@@ -0,0 +1,84 @@
1
+ import { LOCAL_GRAPH_SCHEMA_VERSION } from "./graph/ontology.js";
2
+ import { ORS_VERSION } from "./score/risk.js";
3
+ import { ORANGEPRO_VERSION } from "./version.js";
4
+ import { hashString } from "./util/hash.js";
5
+ export const ARTIFACT_IDENTITY_VERSION = "orangepro.artifact_identity.v1";
6
+ export const ANALYZER_VERSION = "orangepro.analyzer.v2";
7
+ export const PROOF_ORACLE_VERSION = "orangepro.targeted_mutation_oracle.v1";
8
+ function stable(value) {
9
+ if (value === null || typeof value !== "object")
10
+ return JSON.stringify(value);
11
+ if (Array.isArray(value))
12
+ return `[${value.map(stable).join(",")}]`;
13
+ const record = value;
14
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stable(record[key])}`).join(",")}}`;
15
+ }
16
+ function digest(value) {
17
+ return hashString(stable(value));
18
+ }
19
+ /** Included paths + bytes after OrangePro ignore/cap rules. Git metadata is deliberately excluded. */
20
+ export function repositorySnapshot(graph) {
21
+ return digest(Object.entries(graph.manifest.files)
22
+ .sort(([a], [b]) => a.localeCompare(b))
23
+ .map(([path, file]) => ({ path, hash: file.hash })));
24
+ }
25
+ function analysisScope(analysis) {
26
+ return {
27
+ max_files: analysis?.max_files ?? null,
28
+ max_symbols: analysis?.max_symbols ?? null,
29
+ max_inferred_flows: analysis?.max_inferred_flows ?? null,
30
+ files_cap_hit: analysis?.files_cap_hit ?? false,
31
+ symbol_cap_hit: analysis?.symbol_cap_hit ?? false,
32
+ budget_ms: analysis?.not_analyzed_due_to_budget?.budget_ms ?? null,
33
+ flow_options: analysis?.flows?.options ?? null,
34
+ tree_sitter_loaded: [...(analysis?.tree_sitter?.loaded ?? [])].sort(),
35
+ tree_sitter_downgraded: [...(analysis?.tree_sitter?.downgraded ?? [])].sort()
36
+ };
37
+ }
38
+ export function buildArtifactIdentity(graph, inputs) {
39
+ const repository_snapshot = repositorySnapshot(graph);
40
+ const analyzer_version = inputs.analyzerVersion ?? ANALYZER_VERSION;
41
+ const ors_version = inputs.orsVersion ?? ORS_VERSION;
42
+ const oracle_version = inputs.oracleVersion ?? PROOF_ORACLE_VERSION;
43
+ const analysis_fingerprint = digest({
44
+ repository_snapshot,
45
+ analyzer_version,
46
+ graph_schema_version: LOCAL_GRAPH_SCHEMA_VERSION,
47
+ source_upload_policy: graph.workspace.source_upload_policy,
48
+ scope: analysisScope(graph.analysis),
49
+ history: inputs.history
50
+ });
51
+ const ranking_fingerprint = digest({ analysis_fingerprint, risk_config_hash: inputs.configHash, ors_version });
52
+ const run_fingerprint = digest({ ranking_fingerprint, oracle_version });
53
+ return {
54
+ schema_version: ARTIFACT_IDENTITY_VERSION,
55
+ repository_snapshot,
56
+ analysis_fingerprint,
57
+ ranking_fingerprint,
58
+ run_fingerprint,
59
+ analyzer_version,
60
+ graph_schema_version: LOCAL_GRAPH_SCHEMA_VERSION,
61
+ ors_version,
62
+ oracle_version,
63
+ risk_config_hash: inputs.configHash,
64
+ tool_version: ORANGEPRO_VERSION,
65
+ git_commit: graph.manifest.git?.commit ?? null,
66
+ git_dirty: graph.manifest.git?.dirty ?? null
67
+ };
68
+ }
69
+ export function comparisonCompatibility(previous, current) {
70
+ if (!previous || !current)
71
+ return "provenance_incomplete";
72
+ if (previous.schema_version !== ARTIFACT_IDENTITY_VERSION || current.schema_version !== ARTIFACT_IDENTITY_VERSION) {
73
+ return "provenance_incomplete";
74
+ }
75
+ if (previous.repository_snapshot !== current.repository_snapshot)
76
+ return "repository_changed";
77
+ if (previous.analysis_fingerprint !== current.analysis_fingerprint)
78
+ return "analysis_changed";
79
+ if (previous.ranking_fingerprint !== current.ranking_fingerprint)
80
+ return "ranking_changed";
81
+ if (previous.run_fingerprint !== current.run_fingerprint)
82
+ return "experiment_changed";
83
+ return "comparable";
84
+ }
@@ -110,6 +110,19 @@ export function extractImports(file) {
110
110
  }
111
111
  const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, /*setParentNodes*/ false);
112
112
  const imports = [];
113
+ const esmockBindings = new Set();
114
+ // `esmock("./module", overrides)` loads the real target module while replacing
115
+ // selected dependencies. Trust the first argument only when the callee is the
116
+ // default binding imported from the actual `esmock` package. A local helper
117
+ // merely named `esmock` must not create a structural module edge.
118
+ for (const statement of sourceFile.statements) {
119
+ if (ts.isImportDeclaration(statement) &&
120
+ ts.isStringLiteralLike(statement.moduleSpecifier) &&
121
+ statement.moduleSpecifier.text === "esmock" &&
122
+ statement.importClause?.name) {
123
+ esmockBindings.add(statement.importClause.name.text);
124
+ }
125
+ }
113
126
  const moduleText = (node) => node && ts.isStringLiteralLike(node) ? node.text : null;
114
127
  const visit = (node) => {
115
128
  if (ts.isImportDeclaration(node)) {
@@ -129,8 +142,9 @@ export function extractImports(file) {
129
142
  // Dynamic `import("lit")` or `require("lit")` with a string-literal arg.
130
143
  const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
131
144
  const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
145
+ const isEsmock = ts.isIdentifier(node.expression) && esmockBindings.has(node.expression.text);
132
146
  // TODO(low): a concatenated dynamic-import arg yields a phantom specifier; ignored.
133
- if (isDynamicImport || isRequire) {
147
+ if (isDynamicImport || isRequire || isEsmock) {
134
148
  const specifier = moduleText(node.arguments[0]);
135
149
  if (specifier !== null)
136
150
  imports.push({ specifier, kind: "runtime", bindings: [] });
@@ -0,0 +1,15 @@
1
+ export const TEST_SUPPORT_RANKING_REASON_CODE = "test_support_helper";
2
+ const TEST_SUPPORT_PATH_RE = /(^|\/)((?:[^/]+-)?e2e[-_]?testing|testing|testutils?|testhelpers?|fixtures?|mocks?|fakes?)(\/|$)/i;
3
+ /**
4
+ * Built-in ranking-only exclusions. These symbols remain in the graph and keep
5
+ * their denominator eligibility; they simply do not compete with production
6
+ * behaviors in the primary priority worklist.
7
+ */
8
+ export function builtInRankExclusion(file) {
9
+ if (!TEST_SUPPORT_PATH_RE.test(file))
10
+ return undefined;
11
+ return {
12
+ code: TEST_SUPPORT_RANKING_REASON_CODE,
13
+ reason: "Test-support helper — retained in the behavior denominator, excluded from the primary production priority ranking."
14
+ };
15
+ }
@@ -1,5 +1,9 @@
1
1
  import { loadRiskConfig, globToRegExp } from "./riskConfig.js";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { hashString } from "../util/hash.js";
4
+ import { builtInRankExclusion } from "./rankEligibility.js";
5
+ /** Explicit scorer identity. Formula changes require a new value. */
6
+ export const ORS_VERSION = "orangepro.ors.stable_population.v2";
3
7
  const ENTRY_PATH_RE = /(^|\/)(routes?|controllers?|handlers?|jobs?|workers?|processors?|queues?|consumers?|subscribers?|listeners?|server|cmd)\//i;
4
8
  const ENTRY_FILE_RE = /(^|\/)[^/]*(controller|handler|route|router|job|processor|worker|queue|consumer|subscriber|listener|command|gateway)\.[^.\/]+$/i;
5
9
  const API_HANDLER_NAME_RE = /^(GET|POST|PUT|PATCH|DELETE|handle.*|handler|route|controller|endpoint)$/i;
@@ -176,7 +180,26 @@ function gitFirstCommitBatch(root, files) {
176
180
  }
177
181
  return out;
178
182
  }
183
+ /** Exact deterministic identity of the Git-derived inputs consumed by ORS. */
184
+ export function riskHistoryFingerprint(root, files, churnWindow) {
185
+ const health = inspectRiskInputHealth(root, churnWindow);
186
+ const sortedFiles = [...new Set(files)].sort();
187
+ if (!health.churnAvailable) {
188
+ return hashString(JSON.stringify({ state: health.history, window: health.churnWindow, available: false }));
189
+ }
190
+ const churn = gitChurn(root, sortedFiles, health.churnWindow);
191
+ const first = churn.complete ? gitFirstCommitBatch(root, sortedFiles) : new Map();
192
+ return hashString(JSON.stringify({
193
+ state: health.history,
194
+ window: health.churnWindow,
195
+ available: churn.complete,
196
+ churn: sortedFiles.map((file) => [file, churn.values.get(file) ?? 0]),
197
+ first_commit: sortedFiles.map((file) => [file, first.get(file) ?? 0])
198
+ }));
199
+ }
179
200
  export function isEntryPoint(node) {
201
+ if (node.properties.cli_entrypoint === true)
202
+ return true;
180
203
  const file = symbolFile(node);
181
204
  const title = symbolTitle(node);
182
205
  if (API_HANDLER_NAME_RE.test(title) && /(^|\/)api(s)?\//i.test(file))
@@ -194,6 +217,8 @@ function deriveRouteWeight(node) {
194
217
  const method = methodMatch?.[1].toUpperCase() ?? "";
195
218
  const isStore = /\/store\//i.test(file) || /\/store\b/i.test(file);
196
219
  const isAdmin = /\/admin\//i.test(file) || /\/admin\b/i.test(file);
220
+ if (node.properties.cli_entrypoint === true)
221
+ return 6;
197
222
  if (isHttpRouteSymbol(node) && isStore) {
198
223
  if (method === "POST")
199
224
  return 10;
@@ -319,6 +344,10 @@ const NEW_CODE_SECONDS = NEW_CODE_DAYS * 24 * 60 * 60;
319
344
  // `remove` kept but not for listener/handler/attribute/child tails — those detach,
320
345
  // they don't destroy data.
321
346
  const DESTRUCTIVE_CALL_RE = /^(?:delete(?!d)|purge|drop|forcedelete|destroy)[A-Za-z0-9_]*$/i;
347
+ // Replacement can overwrite a remote collection, but the verb is too broad to
348
+ // treat by name alone. It is a sink only when the analyzer proves the receiver
349
+ // is a runtime binding imported from an external module.
350
+ const IMPORTED_REPLACEMENT_CALL_RE = /^replace[A-Za-z0-9_]*$/i;
322
351
  // `remove*` is only a data sink through a persistence-shaped field; in-memory
323
352
  // removals (`pollers.Remove`, `RemoveSpeculativeWorkflowTaskTimeout`) are not.
324
353
  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;
@@ -381,17 +410,19 @@ function candidateSignalIds(graph, candidateIds) {
381
410
  }
382
411
  export function rankRiskGaps(graph, opts = {}) {
383
412
  const limit = opts.limit ?? 20;
384
- const confirmed = confirmedBehaviorIds(graph, opts.provenIds);
413
+ const repoRoot = opts.repoRoot ?? graph.workspace.root;
414
+ const confirmed = opts.includeAssociated
415
+ ? new Set(opts.provenIds ?? [])
416
+ : confirmedBehaviorIds(graph, opts.provenIds);
385
417
  // Fix D: ranking hygiene. Test-support code, bare constants/variables, and trivial
386
418
  // accessors stay in the behavior DENOMINATOR but never compete for a risk slot.
387
- const TEST_SUPPORT_PATH_RE = /(^|\/)(testing|testutils?|testhelpers?|fixtures?|mocks?|fakes?)(\/|$)/i;
388
- const cfgEarly = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.classification;
419
+ const cfgEarly = loadRiskConfig(repoRoot).config.classification;
389
420
  const extraTestSupportRef = cfgEarly.test_support_paths.map(globToRegExp);
390
421
  const rankExcludeRef = cfgEarly.rank_exclude_paths.map(globToRegExp);
391
422
  const ACCESSOR_RE = /(^|\.)(Get|Set|Is|Has)[A-Z][A-Za-z0-9]*$/;
392
423
  const rankEligible = (n) => {
393
424
  const props = (n.properties ?? {});
394
- if (TEST_SUPPORT_PATH_RE.test(symbolFile(n)))
425
+ if (builtInRankExclusion(symbolFile(n)))
395
426
  return false;
396
427
  if (extraTestSupportRef.some((re) => re.test(symbolFile(n))))
397
428
  return false;
@@ -408,12 +439,17 @@ export function rankRiskGaps(graph, opts = {}) {
408
439
  return false;
409
440
  return true;
410
441
  };
411
- const suppressedRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.overrides.filter((o) => o.action === "suppress");
442
+ const suppressedRef = loadRiskConfig(repoRoot).config.overrides.filter((o) => o.action === "suppress");
412
443
  const isSuppressed = (id) => suppressedRef.some((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
413
- const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && !confirmed.has(n.external_id) && rankEligible(n) && !isSuppressed(n.external_id));
414
- const symbolIds = new Set(symbols.map((s) => s.external_id));
444
+ // ORS v2 computes structural inputs and normalizes P/I over the stable
445
+ // denominator-eligible production population. Evidence changes filter the
446
+ // worklist but do not rescale an unchanged peer.
447
+ const normalizationSymbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && rankEligible(n) && !isSuppressed(n.external_id));
448
+ const symbols = normalizationSymbols.filter((n) => !confirmed.has(n.external_id));
449
+ const worklistSymbols = symbols.filter((n) => n.properties.ranking_exclusion_reason !== "python_structural_container");
450
+ const symbolIds = new Set(normalizationSymbols.map((s) => s.external_id));
415
451
  const symbolsByFile = new Map();
416
- for (const s of symbols) {
452
+ for (const s of normalizationSymbols) {
417
453
  const file = symbolFile(s);
418
454
  const list = symbolsByFile.get(file);
419
455
  if (list)
@@ -421,8 +457,7 @@ export function rankRiskGaps(graph, opts = {}) {
421
457
  else
422
458
  symbolsByFile.set(file, [s]);
423
459
  }
424
- const files = [...new Set(symbols.map(symbolFile))];
425
- const repoRoot = opts.repoRoot ?? graph.workspace.root;
460
+ const files = [...new Set(normalizationSymbols.map(symbolFile))];
426
461
  const loadedCfg = loadRiskConfig(repoRoot);
427
462
  const cfg = loadedCfg.config;
428
463
  const overrideFor = (id) => cfg.overrides.find((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
@@ -476,7 +511,7 @@ export function rankRiskGaps(graph, opts = {}) {
476
511
  const total = fileComplexityTotals.get(file) ?? 1;
477
512
  return fileChurn * (Math.max(complexityProxy(s), 1) / Math.max(total, 1));
478
513
  };
479
- const entryPoint = new Map(symbols.map((s) => [s.external_id, isEntryPoint(s)]));
514
+ const entryPoint = new Map(normalizationSymbols.map((s) => [s.external_id, isEntryPoint(s)]));
480
515
  // Single pass over edges (was one full edge scan PER symbol).
481
516
  const fanOutTargets = new Map();
482
517
  for (const e of graph.edges) {
@@ -486,7 +521,7 @@ export function rankRiskGaps(graph, opts = {}) {
486
521
  set.add(e.to_external_id);
487
522
  fanOutTargets.set(e.from_external_id, set);
488
523
  }
489
- const fanOut = new Map(symbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
524
+ const fanOut = new Map(normalizationSymbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
490
525
  // Fix C signals: destructive reach via CALLS (<=3 hops) to a sink; sinks are symbols
491
526
  // whose own name, or a retained external callee, is a destructive call.
492
527
  const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
@@ -503,11 +538,18 @@ export function rankRiskGaps(graph, opts = {}) {
503
538
  if (!n)
504
539
  return undefined;
505
540
  const ext = n.properties?.external_callees ?? [];
541
+ const importedStatic = n.properties?.imported_static_callees ?? [];
506
542
  // A receiver FIELD path is `x.field.Method` — no call parentheses before the last
507
543
  // segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
508
544
  const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
509
545
  const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
510
- return ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
546
+ const destructive = ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
547
+ (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
548
+ extraSinks.some((re) => re.test(lastSeg(c)))));
549
+ if (destructive)
550
+ return destructive;
551
+ return importedStatic.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
552
+ IMPORTED_REPLACEMENT_CALL_RE.test(lastSeg(c)) ||
511
553
  (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
512
554
  extraSinks.some((re) => re.test(lastSeg(c)))));
513
555
  };
@@ -528,17 +570,44 @@ export function rankRiskGaps(graph, opts = {}) {
528
570
  }
529
571
  return undefined;
530
572
  };
531
- const sinkReached = new Map(symbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
573
+ const sinkReached = new Map(normalizationSymbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
532
574
  const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
533
575
  const depthCtx = buildFlowDepthContext(graph);
534
576
  const staticLinked = staticTestLinkedIds(graph, symbolIds);
535
577
  const candidateLinked = candidateSignalIds(graph, symbolIds);
536
578
  const detectionFor = (id) => staticLinked.has(id) ? "associated" : candidateLinked.has(id) ? "candidate" : "none";
537
579
  if (opts.legacy) {
538
- return symbols
580
+ // Preserve the legacy option's historical evidence-filtered attribution.
581
+ const legacyIds = new Set(symbols.map((s) => s.external_id));
582
+ const legacyByFile = new Map();
583
+ for (const s of symbols) {
584
+ const file = symbolFile(s);
585
+ const list = legacyByFile.get(file);
586
+ if (list)
587
+ list.push(s);
588
+ else
589
+ legacyByFile.set(file, [s]);
590
+ }
591
+ const legacyIncoming = new Map();
592
+ const legacyFileImports = new Map();
593
+ for (const e of graph.edges) {
594
+ if (e.relationship_type === "CALLS" && legacyIds.has(e.to_external_id)) {
595
+ legacyIncoming.set(e.to_external_id, (legacyIncoming.get(e.to_external_id) ?? 0) + 1);
596
+ }
597
+ else if (e.relationship_type === "IMPORTS" && legacyByFile.has(e.to_external_id)) {
598
+ legacyFileImports.set(e.to_external_id, (legacyFileImports.get(e.to_external_id) ?? 0) + 1);
599
+ }
600
+ }
601
+ for (const [file, count] of legacyFileImports) {
602
+ const syms = legacyByFile.get(file) ?? [];
603
+ const share = syms.length > 0 ? count / syms.length : 0;
604
+ for (const s of syms)
605
+ legacyIncoming.set(s.external_id, (legacyIncoming.get(s.external_id) ?? 0) + share);
606
+ }
607
+ return worklistSymbols
539
608
  .map((s) => {
540
609
  const file = symbolFile(s);
541
- const incoming_refs = incoming.get(s.external_id) ?? 0;
610
+ const incoming_refs = legacyIncoming.get(s.external_id) ?? 0;
542
611
  const git_churn = churn.get(file) ?? 0;
543
612
  const isEntry = entryPoint.get(s.external_id) ?? false;
544
613
  const churnForScore = Math.min(git_churn, 500);
@@ -556,7 +625,7 @@ export function rankRiskGaps(graph, opts = {}) {
556
625
  .sort((a, b) => b.risk_score - a.risk_score || b.incoming_refs - a.incoming_refs || b.git_churn - a.git_churn || a.id.localeCompare(b.id))
557
626
  .slice(0, limit);
558
627
  }
559
- const rawScores = symbols.map((s) => {
628
+ const rawScores = normalizationSymbols.map((s) => {
560
629
  const file = symbolFile(s);
561
630
  const incoming_refs = incoming.get(s.external_id) ?? 0;
562
631
  const git_churn = symbolChurn(s);
@@ -578,15 +647,19 @@ export function rankRiskGaps(graph, opts = {}) {
578
647
  // matter how few callers it has. Impact FLOORS at 5/10 for such paths (a floor,
579
648
  // not an increment — an additive bump vanishes under hub-dominated normalization).
580
649
  const iScores = iScoresRaw.map((v, idx) => (rawScores[idx].reachesDestructiveSink ? Math.max(v, 5) : v));
581
- const ranked = symbols
582
- .map((s, idx) => {
650
+ const rawById = new Map(normalizationSymbols.map((s, idx) => [s.external_id, rawScores[idx]]));
651
+ const pById = new Map(normalizationSymbols.map((s, idx) => [s.external_id, pScores[idx]]));
652
+ const iById = new Map(normalizationSymbols.map((s, idx) => [s.external_id, iScores[idx]]));
653
+ const ranked = worklistSymbols
654
+ .map((s) => {
583
655
  const file = symbolFile(s);
584
656
  const incoming_refs = Math.round((incoming.get(s.external_id) ?? 0) * 10) / 10;
585
657
  const git_churn = Math.round(symbolChurn(s));
586
658
  const fan_out = fanOut.get(s.external_id) ?? 0;
587
659
  const isEntry = entryPoint.get(s.external_id) ?? false;
588
660
  const route_weight = deriveRouteWeight(s);
589
- const data_sensitivity = rawScores[idx].sensitivityOverride !== undefined ? rawScores[idx].sensitivityOverride : rawScores[idx].sensitivityIgnored ? 0 : deriveDataSensitivity(s);
661
+ const raw = rawById.get(s.external_id);
662
+ const data_sensitivity = raw.sensitivityOverride !== undefined ? raw.sensitivityOverride : raw.sensitivityIgnored ? 0 : deriveDataSensitivity(s);
590
663
  const flow_position = Math.max(0, 5 - getFlowDepth(s, depthCtx));
591
664
  const complexity_proxy = complexityProxy(s);
592
665
  const firstTs = firstCommitTs.get(file) ?? 0;
@@ -595,11 +668,11 @@ export function rankRiskGaps(graph, opts = {}) {
595
668
  // before multiplying previously collapsed whole hot files into identical
596
669
  // P×I×D ties (seventeen ORS-100 rows from one service). Integers remain
597
670
  // display-only in the decomposition string.
598
- const pExact = pScores[idx];
599
- const iExact = iScores[idx];
671
+ const pExact = pById.get(s.external_id);
672
+ const iExact = iById.get(s.external_id);
600
673
  const p = Math.round(pExact);
601
674
  const i = Math.round(iExact);
602
- const d = rawScores[idx].d;
675
+ const d = raw.d;
603
676
  const detectionTier = detectionFor(s.external_id);
604
677
  let score = Math.round(pExact * iExact * d * 10) / 10;
605
678
  const disconnected = incoming_refs === 0 && fan_out === 0;
@@ -623,9 +696,9 @@ export function rankRiskGaps(graph, opts = {}) {
623
696
  reasons.push("lexical candidate test match only — unconfirmed");
624
697
  // Reason chips for the two consequence signals — a reader can disagree with the
625
698
  // weight without doubting the fact, and the fact is what's shown.
626
- if (rawScores[idx].reachesDestructiveSink)
699
+ if (raw.reachesDestructiveSink)
627
700
  reasons.push("reaches a destructive external call (delete/purge) within 2 hops — impact floored at 5");
628
- if (rawScores[idx].scheduledEntry)
701
+ if (raw.scheduledEntry)
629
702
  reasons.push("scheduled/queue-triggered entry with no proof — failures surface nowhere, detection ×1.25");
630
703
  const override = overrideFor(s.external_id);
631
704
  if (override && override.action !== "suppress")
@@ -633,7 +706,7 @@ export function rankRiskGaps(graph, opts = {}) {
633
706
  return {
634
707
  ...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
635
708
  ...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
636
- ...(rawScores[idx].scheduledEntry ? { scheduled_entry: true } : {}),
709
+ ...(raw.scheduledEntry ? { scheduled_entry: true } : {}),
637
710
  detection_tier: detectionTier,
638
711
  id: s.external_id,
639
712
  title: s.title || s.external_id,
@@ -1,12 +1,11 @@
1
1
  import { loadRiskConfig } from "../score/riskConfig.js";
2
2
  import { configDisclosureFor } from "../score/risk.js";
3
- import { createHash } from "node:crypto";
4
3
  import path from "node:path";
5
4
  import { buildRtm } from "../rtm.js";
6
- import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps } from "../score/risk.js";
7
- import { ORANGEPRO_VERSION } from "../version.js";
5
+ import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps, riskHistoryFingerprint } from "../score/risk.js";
8
6
  import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
9
7
  import { classifyGeneratedDraftBlocker } from "../generate/draftGuidance.js";
8
+ import { buildArtifactIdentity, comparisonCompatibility } from "../provenance.js";
10
9
  /** Short human phrase per R-1 needs_setup category, for the "blocked because: …" panel copy. */
11
10
  const BLOCK_CATEGORY_LABEL = {
12
11
  module_not_found: "a missing module or dependency in the sandbox",
@@ -219,6 +218,16 @@ function proofGuidance(ledger, summary, dyn) {
219
218
  action: HANDOFF_ACTION
220
219
  };
221
220
  }
221
+ export function excludedCliCommands(graph) {
222
+ return graph.nodes
223
+ .filter((node) => node.kind === "CodeSymbol" && node.properties.cli_entrypoint === true && node.denominator_eligible !== true)
224
+ .map((node) => ({
225
+ path: node.title ?? node.external_id,
226
+ file: String(node.properties.file ?? ""),
227
+ reason: node.denominator_reason ?? "Excluded from the behavior denominator."
228
+ }))
229
+ .sort((a, b) => a.file.localeCompare(b.file) || a.path.localeCompare(b.path));
230
+ }
222
231
  function scanBlock(graph, rows) {
223
232
  const services = new Map();
224
233
  for (const row of rows) {
@@ -259,7 +268,8 @@ function scanBlock(graph, rows) {
259
268
  excluded: {
260
269
  count: excludedCount > 0 ? String(excludedCount) : "0",
261
270
  text: "non-behavior symbols were excluded from the behavior count — generated code, framework internals, test-inferred flows, and infrastructure plumbing."
262
- }
271
+ },
272
+ excludedCliCommands: excludedCliCommands(graph)
263
273
  };
264
274
  }
265
275
  function behaviorLists(rows, flowIds) {
@@ -344,6 +354,7 @@ function flows(graph, rows, risks) {
344
354
  title: flow.entry_point.title || flow.entry_point.external_id,
345
355
  trigger,
346
356
  root_entry,
357
+ ...(rootNode?.properties.entry_lane === "cli" ? { entry_lane: "cli" } : {}),
347
358
  risk: risk ? riskBucket(risk.risk_score, maxRiskScore) : null,
348
359
  proof,
349
360
  services,
@@ -500,6 +511,24 @@ function riskContext(risk) {
500
511
  /** Pure delta between a persisted baseline and the current report data.
501
512
  * Deterministic: same inputs, same delta. */
502
513
  export function computeReportDelta(prev, cur) {
514
+ const comparisonState = comparisonCompatibility(prev.identity, cur.provenance.identity);
515
+ if (comparisonState !== "comparable") {
516
+ return {
517
+ baselineTs: prev.ts,
518
+ comparisonState,
519
+ comparisonReason: comparisonReason(comparisonState),
520
+ changed: false,
521
+ totalDelta: 0,
522
+ provenDelta: 0,
523
+ associatedDelta: 0,
524
+ candidateDelta: 0,
525
+ noneDelta: 0,
526
+ newRisks: [],
527
+ droppedRisks: [],
528
+ generatedDelta: 0,
529
+ configChanged: comparisonState === "ranking_changed"
530
+ };
531
+ }
503
532
  const curPaths = cur.risks.map((r) => r.path);
504
533
  const prevSet = new Set(prev.riskPaths);
505
534
  const curSet = new Set(curPaths);
@@ -507,6 +536,8 @@ export function computeReportDelta(prev, cur) {
507
536
  const droppedRisks = prev.riskPaths.filter((p) => !curSet.has(p));
508
537
  const d = {
509
538
  baselineTs: prev.ts,
539
+ comparisonState,
540
+ comparisonReason: comparisonReason(comparisonState),
510
541
  changed: false,
511
542
  totalDelta: cur.summary.total - prev.summary.total,
512
543
  provenDelta: cur.summary.proven - prev.summary.proven,
@@ -524,7 +555,34 @@ export function computeReportDelta(prev, cur) {
524
555
  return d;
525
556
  }
526
557
  export function reportBaselineOf(cur, ts) {
527
- return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal, configHash: cur.provenance?.configHash };
558
+ return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal, configHash: cur.provenance?.configHash, identity: cur.provenance.identity };
559
+ }
560
+ export function firstRunDelta() {
561
+ return {
562
+ baselineTs: null,
563
+ comparisonState: "first_run",
564
+ comparisonReason: comparisonReason("first_run"),
565
+ changed: false,
566
+ totalDelta: 0,
567
+ provenDelta: 0,
568
+ associatedDelta: 0,
569
+ candidateDelta: 0,
570
+ noneDelta: 0,
571
+ newRisks: [],
572
+ droppedRisks: [],
573
+ generatedDelta: 0
574
+ };
575
+ }
576
+ function comparisonReason(state) {
577
+ switch (state) {
578
+ case "first_run": return "No prior identity-bearing baseline exists.";
579
+ case "comparable": return "Repository, analysis, ranking and proof-oracle identities match.";
580
+ case "repository_changed": return "The scanned repository paths or bytes changed; trend deltas are withheld.";
581
+ case "analysis_changed": return "Analyzer, schema, scope or history inputs changed; treat this as structural recalibration.";
582
+ case "ranking_changed": return "Risk configuration or ORS version changed; treat this as ranking recalibration.";
583
+ case "experiment_changed": return "A proof-oracle or declared experiment input changed; only controlled-experiment interpretation is valid.";
584
+ case "provenance_incomplete": return "The previous artifact lacks the required versioned identity; automated comparison is disabled.";
585
+ }
528
586
  }
529
587
  const HTTP_TRIGGER_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"]);
530
588
  const GRAPHQL_TRIGGER_VERBS = new Set(["MUTATION", "QUERY", "SUBSCRIPTION"]);
@@ -571,6 +629,8 @@ export function buildSystemMapModel(data, maxServices = 12) {
571
629
  const laneForFlow = (flow) => {
572
630
  if (flow.trigger)
573
631
  return laneForTrigger(flow.trigger);
632
+ if (flow.entry_lane === "cli")
633
+ return { id: "cli", label: "CLI", priority: 60 };
574
634
  // A program root without a framework trigger is still a real product entry
575
635
  // point (CLI/main). Internal call-graph roots remain excluded.
576
636
  return flow.root_entry ? { id: "entry", label: "Entry points", priority: 80 } : null;
@@ -939,10 +999,30 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
939
999
  .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, probability: r.probability ?? 0 }));
940
1000
  // Fix: search the FULL ranking for sinks, not the top 200 — a stable destructive path
941
1001
  // ranked 300th is exactly what this panel exists to recover.
942
- const irreversible = rankRiskGaps(graph, { repoRoot, limit: Number.MAX_SAFE_INTEGER, provenIds }).filter((r) => r.sink_callee).slice(0, 20)
1002
+ // Consequence view: a static test association is not dynamic proof. Keep
1003
+ // Associated sink paths visible here while the main priority-gap list retains
1004
+ // its existing behavior and excludes hard-linked rows.
1005
+ const irreversible = rankRiskGaps(graph, {
1006
+ repoRoot,
1007
+ limit: Number.MAX_SAFE_INTEGER,
1008
+ provenIds,
1009
+ includeAssociated: true
1010
+ }).filter((r) => r.sink_callee).slice(0, 20)
943
1011
  .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, sink: r.sink_callee ?? "" }));
944
1012
  const riskHealth = inspectRiskInputHealth(repoRoot);
945
1013
  const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
1014
+ const configHash = loadRiskConfig(repoRoot).hash;
1015
+ const codeFiles = Object.entries(graph.manifest.files).filter(([, file]) => file.kind === "code").map(([file]) => file);
1016
+ const identity = buildArtifactIdentity(graph, {
1017
+ configHash,
1018
+ history: {
1019
+ state: riskHealth.history,
1020
+ churnWindow: riskHealth.churnWindow,
1021
+ churnAvailable,
1022
+ commitDate: riskHealth.commitDate,
1023
+ inputFingerprint: riskHistoryFingerprint(repoRoot, codeFiles, riskHealth.churnWindow)
1024
+ }
1025
+ });
946
1026
  const provenance = {
947
1027
  source: path.basename(repoRoot || graph.workspace.name || "repo"),
948
1028
  gitRoot: riskHealth.gitRoot ? path.basename(riskHealth.gitRoot) : null,
@@ -950,12 +1030,10 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
950
1030
  history: riskHealth.history,
951
1031
  churn: churnAvailable ? "available" : "unavailable",
952
1032
  churnWindow: riskHealth.churnWindow,
953
- toolVersion: ORANGEPRO_VERSION,
954
- configHash: loadRiskConfig(repoRoot).hash,
955
- inputFingerprint: createHash("sha256")
956
- .update(JSON.stringify({ root: graph.workspace.root_hash, commit: riskHealth.commit, history: riskHealth.history, churn: churnAvailable, window: riskHealth.churnWindow, version: ORANGEPRO_VERSION, config: loadRiskConfig(repoRoot).hash }))
957
- .digest("hex")
958
- .slice(0, 16),
1033
+ toolVersion: identity.tool_version,
1034
+ configHash,
1035
+ identity,
1036
+ inputFingerprint: identity.run_fingerprint.replace(/^sha256:/, "").slice(0, 16),
959
1037
  reason: churnAvailable ? undefined : (riskHealth.reason ?? "Git churn scan did not complete")
960
1038
  };
961
1039
  const lists = behaviorLists(rows, flowIds);
@@ -982,6 +1060,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
982
1060
  configDisclosure: configDisclosureFor(graph, repoRoot),
983
1061
  zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
984
1062
  mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
1063
+ delta: firstRunDelta(),
985
1064
  viewMeta: {
986
1065
  // Every denominator behavior is scored; the risks tab surfaces the top N.
987
1066
  risks: { shown: risks.length, scored: summary.total },
@@ -50,6 +50,7 @@ a{color:var(--blue);text-decoration:none}
50
50
  .kpi[data-t="nosig"]{--kw:var(--rbg)} .kpi[data-t="nosig"] .kpi-num{color:var(--red)}
51
51
  .kpi[data-t="priority"]{--kw:var(--obg)} .kpi[data-t="priority"] .kpi-num{color:var(--orange)}
52
52
  .metric-scope{margin:9px 2px 0;color:var(--muted);font-size:11.5px;line-height:1.5}
53
+ .scope-note{margin:9px 0 0;padding:9px 11px;border:1px solid var(--abd);background:var(--abg);color:var(--ink2);font-size:11.5px;line-height:1.5;border-radius:6px}
53
54
 
54
55
  /* TABS */
55
56
  nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
@@ -343,6 +344,7 @@ body[data-mode="expert"] .simple-only{display:none!important}
343
344
  </div>
344
345
  </header>
345
346
  <div class="provenance" id="provenance"></div>
347
+ <div class="scope-note" id="cli-exclusions" hidden></div>
346
348
 
347
349
  <section class="kpis" id="kpis"></section>
348
350
  <p class="metric-scope" id="metric-scope"></p>
@@ -465,9 +467,13 @@ const D=window.DATA,$=(s)=>document.querySelector(s);
465
467
  var el2=document.getElementById("delta-banner");
466
468
  var d=D.delta;
467
469
  if(!el2||d===undefined)return;
468
- if(d===null){el2.innerHTML='';return;} // first run — baseline just recorded, nothing to compare
470
+ if(d.comparisonState==='first_run'){el2.innerHTML='';return;}
469
471
  var when=new Date(d.baselineTs);
470
472
  var ago=isNaN(when.getTime())?"last run":when.toLocaleString();
473
+ if(d.comparisonState!=='comparable'){
474
+ el2.innerHTML='<div class="delta-wrap"><span class="delta-chip dc-none">Comparison withheld: '+d.comparisonReason+'</span></div>';
475
+ return;
476
+ }
471
477
  if(!d.changed){
472
478
  el2.innerHTML='<div class="delta-wrap"><span class="delta-chip dc-none">No report changes since last run ('+ago+') for the recorded inputs</span></div>';
473
479
  return;
@@ -515,7 +521,12 @@ function scrollToRisk(path){
515
521
  (function(){
516
522
  var hero=document.getElementById("delta-hero");
517
523
  var d=D.delta;
518
- if(!hero||d===undefined||d===null)return;
524
+ if(!hero||d===undefined||d.comparisonState==='first_run')return;
525
+ if(d.comparisonState!=='comparable'){
526
+ hero.className="delta-hero simple-only dh-clear";
527
+ hero.innerHTML='<h3>Comparison withheld</h3><p>'+d.comparisonReason+'</p>';
528
+ return;
529
+ }
519
530
  if(!d.changed){
520
531
  var when=new Date(d.baselineTs);
521
532
  var ago=isNaN(when.getTime())?"last run":when.toLocaleDateString();
@@ -677,6 +688,12 @@ const prov=$("#provenance");
677
688
  const provisional=P.churn!=="available";
678
689
  prov.classList.toggle("warn",provisional);
679
690
  prov.textContent=(provisional?"PROVISIONAL RANKING · ":"VERIFIED INPUTS · ")+"source "+P.source+" · commit "+(P.commit?P.commit.slice(0,12):"unavailable")+" · history "+P.history+" · churn "+P.churn+" ("+P.churnWindow+") · OrangePro "+P.toolVersion+" · input "+P.inputFingerprint+(P.reason?" · "+P.reason:"");
691
+ const excludedCli=D.scan.excludedCliCommands||[];
692
+ if(excludedCli.length){
693
+ const note=$("#cli-exclusions");
694
+ note.hidden=false;
695
+ note.textContent="CLI command entries excluded from the behavior count: "+excludedCli.map(x=>x.path+" ("+x.reason+")").join(" · ");
696
+ }
680
697
  $("#fw-pill").textContent=D.framework;
681
698
 
682
699
  // bridge text in codebase tab