@orangepro/orangepro-mcp 0.2.40 → 0.2.42

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.
@@ -104,6 +104,7 @@ export function distillProofAttempts(auto, meta) {
104
104
  graph_generated_at: manifest?.generated_at ?? null,
105
105
  git_commit: manifest?.git?.commit ?? null,
106
106
  git_dirty: manifest?.git?.dirty ?? null,
107
+ artifact_identity: meta.graph.artifact_identity,
107
108
  attempted: auto.attempted,
108
109
  proven: auto.proven,
109
110
  attempts: auto.attempts.map((a) => ({
@@ -128,11 +129,11 @@ export function writeProofAttempts(root, file) {
128
129
  writeFileSync(path, JSON.stringify(file, null, 2) + "\n", "utf8");
129
130
  return path;
130
131
  }
131
- /** True when the sidecar anchors to the CURRENT graph generation + commit. */
132
+ /** True when the sidecar anchors to the CURRENT material run inputs. */
132
133
  export function proofAttemptsFresh(attempts, graph) {
133
- const manifest = graph.manifest;
134
- return (attempts.graph_generated_at === (manifest?.generated_at ?? null) &&
135
- attempts.git_commit === (manifest?.git?.commit ?? null));
134
+ return Boolean(attempts.artifact_identity?.run_fingerprint &&
135
+ graph.artifact_identity?.run_fingerprint &&
136
+ attempts.artifact_identity.run_fingerprint === graph.artifact_identity.run_fingerprint);
136
137
  }
137
138
  export function loadProofAttempts(root) {
138
139
  const path = proofAttemptsPath(root);
@@ -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.v1";
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,16 @@ 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 symbolIds = new Set(normalizationSymbols.map((s) => s.external_id));
415
450
  const symbolsByFile = new Map();
416
- for (const s of symbols) {
451
+ for (const s of normalizationSymbols) {
417
452
  const file = symbolFile(s);
418
453
  const list = symbolsByFile.get(file);
419
454
  if (list)
@@ -421,8 +456,7 @@ export function rankRiskGaps(graph, opts = {}) {
421
456
  else
422
457
  symbolsByFile.set(file, [s]);
423
458
  }
424
- const files = [...new Set(symbols.map(symbolFile))];
425
- const repoRoot = opts.repoRoot ?? graph.workspace.root;
459
+ const files = [...new Set(normalizationSymbols.map(symbolFile))];
426
460
  const loadedCfg = loadRiskConfig(repoRoot);
427
461
  const cfg = loadedCfg.config;
428
462
  const overrideFor = (id) => cfg.overrides.find((o) => o.symbol === id || globToRegExp(o.symbol).test(id));
@@ -476,7 +510,7 @@ export function rankRiskGaps(graph, opts = {}) {
476
510
  const total = fileComplexityTotals.get(file) ?? 1;
477
511
  return fileChurn * (Math.max(complexityProxy(s), 1) / Math.max(total, 1));
478
512
  };
479
- const entryPoint = new Map(symbols.map((s) => [s.external_id, isEntryPoint(s)]));
513
+ const entryPoint = new Map(normalizationSymbols.map((s) => [s.external_id, isEntryPoint(s)]));
480
514
  // Single pass over edges (was one full edge scan PER symbol).
481
515
  const fanOutTargets = new Map();
482
516
  for (const e of graph.edges) {
@@ -486,7 +520,7 @@ export function rankRiskGaps(graph, opts = {}) {
486
520
  set.add(e.to_external_id);
487
521
  fanOutTargets.set(e.from_external_id, set);
488
522
  }
489
- const fanOut = new Map(symbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
523
+ const fanOut = new Map(normalizationSymbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
490
524
  // Fix C signals: destructive reach via CALLS (<=3 hops) to a sink; sinks are symbols
491
525
  // whose own name, or a retained external callee, is a destructive call.
492
526
  const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
@@ -503,11 +537,18 @@ export function rankRiskGaps(graph, opts = {}) {
503
537
  if (!n)
504
538
  return undefined;
505
539
  const ext = n.properties?.external_callees ?? [];
540
+ const importedStatic = n.properties?.imported_static_callees ?? [];
506
541
  // A receiver FIELD path is `x.field.Method` — no call parentheses before the last
507
542
  // segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
508
543
  const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
509
544
  const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
510
- return ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
545
+ const destructive = ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
546
+ (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
547
+ extraSinks.some((re) => re.test(lastSeg(c)))));
548
+ if (destructive)
549
+ return destructive;
550
+ return importedStatic.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
551
+ IMPORTED_REPLACEMENT_CALL_RE.test(lastSeg(c)) ||
511
552
  (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
512
553
  extraSinks.some((re) => re.test(lastSeg(c)))));
513
554
  };
@@ -528,17 +569,44 @@ export function rankRiskGaps(graph, opts = {}) {
528
569
  }
529
570
  return undefined;
530
571
  };
531
- const sinkReached = new Map(symbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
572
+ const sinkReached = new Map(normalizationSymbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
532
573
  const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
533
574
  const depthCtx = buildFlowDepthContext(graph);
534
575
  const staticLinked = staticTestLinkedIds(graph, symbolIds);
535
576
  const candidateLinked = candidateSignalIds(graph, symbolIds);
536
577
  const detectionFor = (id) => staticLinked.has(id) ? "associated" : candidateLinked.has(id) ? "candidate" : "none";
537
578
  if (opts.legacy) {
579
+ // Preserve the legacy option's historical evidence-filtered attribution.
580
+ const legacyIds = new Set(symbols.map((s) => s.external_id));
581
+ const legacyByFile = new Map();
582
+ for (const s of symbols) {
583
+ const file = symbolFile(s);
584
+ const list = legacyByFile.get(file);
585
+ if (list)
586
+ list.push(s);
587
+ else
588
+ legacyByFile.set(file, [s]);
589
+ }
590
+ const legacyIncoming = new Map();
591
+ const legacyFileImports = new Map();
592
+ for (const e of graph.edges) {
593
+ if (e.relationship_type === "CALLS" && legacyIds.has(e.to_external_id)) {
594
+ legacyIncoming.set(e.to_external_id, (legacyIncoming.get(e.to_external_id) ?? 0) + 1);
595
+ }
596
+ else if (e.relationship_type === "IMPORTS" && legacyByFile.has(e.to_external_id)) {
597
+ legacyFileImports.set(e.to_external_id, (legacyFileImports.get(e.to_external_id) ?? 0) + 1);
598
+ }
599
+ }
600
+ for (const [file, count] of legacyFileImports) {
601
+ const syms = legacyByFile.get(file) ?? [];
602
+ const share = syms.length > 0 ? count / syms.length : 0;
603
+ for (const s of syms)
604
+ legacyIncoming.set(s.external_id, (legacyIncoming.get(s.external_id) ?? 0) + share);
605
+ }
538
606
  return symbols
539
607
  .map((s) => {
540
608
  const file = symbolFile(s);
541
- const incoming_refs = incoming.get(s.external_id) ?? 0;
609
+ const incoming_refs = legacyIncoming.get(s.external_id) ?? 0;
542
610
  const git_churn = churn.get(file) ?? 0;
543
611
  const isEntry = entryPoint.get(s.external_id) ?? false;
544
612
  const churnForScore = Math.min(git_churn, 500);
@@ -556,7 +624,7 @@ export function rankRiskGaps(graph, opts = {}) {
556
624
  .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
625
  .slice(0, limit);
558
626
  }
559
- const rawScores = symbols.map((s) => {
627
+ const rawScores = normalizationSymbols.map((s) => {
560
628
  const file = symbolFile(s);
561
629
  const incoming_refs = incoming.get(s.external_id) ?? 0;
562
630
  const git_churn = symbolChurn(s);
@@ -578,15 +646,19 @@ export function rankRiskGaps(graph, opts = {}) {
578
646
  // matter how few callers it has. Impact FLOORS at 5/10 for such paths (a floor,
579
647
  // not an increment — an additive bump vanishes under hub-dominated normalization).
580
648
  const iScores = iScoresRaw.map((v, idx) => (rawScores[idx].reachesDestructiveSink ? Math.max(v, 5) : v));
649
+ const rawById = new Map(normalizationSymbols.map((s, idx) => [s.external_id, rawScores[idx]]));
650
+ const pById = new Map(normalizationSymbols.map((s, idx) => [s.external_id, pScores[idx]]));
651
+ const iById = new Map(normalizationSymbols.map((s, idx) => [s.external_id, iScores[idx]]));
581
652
  const ranked = symbols
582
- .map((s, idx) => {
653
+ .map((s) => {
583
654
  const file = symbolFile(s);
584
655
  const incoming_refs = Math.round((incoming.get(s.external_id) ?? 0) * 10) / 10;
585
656
  const git_churn = Math.round(symbolChurn(s));
586
657
  const fan_out = fanOut.get(s.external_id) ?? 0;
587
658
  const isEntry = entryPoint.get(s.external_id) ?? false;
588
659
  const route_weight = deriveRouteWeight(s);
589
- const data_sensitivity = rawScores[idx].sensitivityOverride !== undefined ? rawScores[idx].sensitivityOverride : rawScores[idx].sensitivityIgnored ? 0 : deriveDataSensitivity(s);
660
+ const raw = rawById.get(s.external_id);
661
+ const data_sensitivity = raw.sensitivityOverride !== undefined ? raw.sensitivityOverride : raw.sensitivityIgnored ? 0 : deriveDataSensitivity(s);
590
662
  const flow_position = Math.max(0, 5 - getFlowDepth(s, depthCtx));
591
663
  const complexity_proxy = complexityProxy(s);
592
664
  const firstTs = firstCommitTs.get(file) ?? 0;
@@ -595,11 +667,11 @@ export function rankRiskGaps(graph, opts = {}) {
595
667
  // before multiplying previously collapsed whole hot files into identical
596
668
  // P×I×D ties (seventeen ORS-100 rows from one service). Integers remain
597
669
  // display-only in the decomposition string.
598
- const pExact = pScores[idx];
599
- const iExact = iScores[idx];
670
+ const pExact = pById.get(s.external_id);
671
+ const iExact = iById.get(s.external_id);
600
672
  const p = Math.round(pExact);
601
673
  const i = Math.round(iExact);
602
- const d = rawScores[idx].d;
674
+ const d = raw.d;
603
675
  const detectionTier = detectionFor(s.external_id);
604
676
  let score = Math.round(pExact * iExact * d * 10) / 10;
605
677
  const disconnected = incoming_refs === 0 && fan_out === 0;
@@ -623,9 +695,9 @@ export function rankRiskGaps(graph, opts = {}) {
623
695
  reasons.push("lexical candidate test match only — unconfirmed");
624
696
  // Reason chips for the two consequence signals — a reader can disagree with the
625
697
  // weight without doubting the fact, and the fact is what's shown.
626
- if (rawScores[idx].reachesDestructiveSink)
698
+ if (raw.reachesDestructiveSink)
627
699
  reasons.push("reaches a destructive external call (delete/purge) within 2 hops — impact floored at 5");
628
- if (rawScores[idx].scheduledEntry)
700
+ if (raw.scheduledEntry)
629
701
  reasons.push("scheduled/queue-triggered entry with no proof — failures surface nowhere, detection ×1.25");
630
702
  const override = overrideFor(s.external_id);
631
703
  if (override && override.action !== "suppress")
@@ -633,7 +705,7 @@ export function rankRiskGaps(graph, opts = {}) {
633
705
  return {
634
706
  ...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
635
707
  ...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
636
- ...(rawScores[idx].scheduledEntry ? { scheduled_entry: true } : {}),
708
+ ...(raw.scheduledEntry ? { scheduled_entry: true } : {}),
637
709
  detection_tier: detectionTier,
638
710
  id: s.external_id,
639
711
  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 },