@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.
- package/dist/local/analyze/analyzer.js +163 -29
- package/dist/local/analyze/classify.js +5 -1
- package/dist/local/analyze/confirm.js +90 -6
- package/dist/local/analyze/frameworks.js +3 -0
- package/dist/local/flows/flowWalker.js +1 -1
- package/dist/local/operations.js +30 -5
- package/dist/local/proofDoctor.js +5 -4
- package/dist/local/provenance.js +84 -0
- package/dist/local/resolve/importGraph.js +15 -1
- package/dist/local/score/rankEligibility.js +15 -0
- package/dist/local/score/risk.js +96 -24
- package/dist/local/viz/behaviorReportData.js +91 -12
- package/dist/local/viz/behaviorReportHtml.js +36 -7
- package/dist/local/viz/html.js +3 -3
- package/package.json +1 -1
|
@@ -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
|
|
132
|
+
/** True when the sidecar anchors to the CURRENT material run inputs. */
|
|
132
133
|
export function proofAttemptsFresh(attempts, graph) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
attempts.
|
|
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
|
+
}
|
package/dist/local/score/risk.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
599
|
-
const iExact =
|
|
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 =
|
|
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 (
|
|
698
|
+
if (raw.reachesDestructiveSink)
|
|
627
699
|
reasons.push("reaches a destructive external call (delete/purge) within 2 hops — impact floored at 5");
|
|
628
|
-
if (
|
|
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
|
-
...(
|
|
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
|
-
|
|
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:
|
|
954
|
-
configHash
|
|
955
|
-
|
|
956
|
-
|
|
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 },
|