@orangepro/orangepro-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/local/agentWorkflow.js +81 -0
- package/dist/local/aiGraph/links.js +635 -0
- package/dist/local/analyze/analyzer.js +2129 -0
- package/dist/local/analyze/behaviorContracts.js +169 -0
- package/dist/local/analyze/boilerplate.js +42 -0
- package/dist/local/analyze/callGraph.js +458 -0
- package/dist/local/analyze/classify.js +219 -0
- package/dist/local/analyze/clustering.js +357 -0
- package/dist/local/analyze/confirm.js +2422 -0
- package/dist/local/analyze/coverage.js +518 -0
- package/dist/local/analyze/coverageArtifacts.js +607 -0
- package/dist/local/analyze/frameworks.js +115 -0
- package/dist/local/analyze/linkage/conventions.js +160 -0
- package/dist/local/analyze/parseCache.js +164 -0
- package/dist/local/analyze/selfAssert.js +53 -0
- package/dist/local/analyze/symbols.js +430 -0
- package/dist/local/analyze/testLayer.js +135 -0
- package/dist/local/analyze/treeSitter/engine.js +1253 -0
- package/dist/local/analyze/treeSitter/languages.js +101 -0
- package/dist/local/autoProve.js +620 -0
- package/dist/local/cli.js +1468 -0
- package/dist/local/cliArgs.js +112 -0
- package/dist/local/corpusScope.js +162 -0
- package/dist/local/enrich/csv.js +348 -0
- package/dist/local/enrich/index.js +43 -0
- package/dist/local/enrich/markdown.js +193 -0
- package/dist/local/explain/explain.js +91 -0
- package/dist/local/exportCli.js +26 -0
- package/dist/local/flows/flowWalker.js +215 -0
- package/dist/local/flows/llmFlowDiscovery.js +567 -0
- package/dist/local/freshness/changed.js +280 -0
- package/dist/local/freshness/manifest.js +35 -0
- package/dist/local/freshness/status.js +30 -0
- package/dist/local/gaps/gaps.js +114 -0
- package/dist/local/generate/buckets.js +73 -0
- package/dist/local/generate/compareJudge.js +124 -0
- package/dist/local/generate/compareReport.js +538 -0
- package/dist/local/generate/compareScore.js +105 -0
- package/dist/local/generate/deriveImports.js +91 -0
- package/dist/local/generate/generator.js +2586 -0
- package/dist/local/generate/prompt.js +144 -0
- package/dist/local/generate/promptV5.js +438 -0
- package/dist/local/generate/providers.js +400 -0
- package/dist/local/generate/runHints.js +304 -0
- package/dist/local/graph/citations.js +73 -0
- package/dist/local/graph/confirmable.js +72 -0
- package/dist/local/graph/factories.js +210 -0
- package/dist/local/graph/ontology.js +18 -0
- package/dist/local/interactive.js +53 -0
- package/dist/local/jobs/jobStore.js +80 -0
- package/dist/local/jobs/notify.js +29 -0
- package/dist/local/jobs/runner.js +75 -0
- package/dist/local/ledger.js +117 -0
- package/dist/local/localConfig.js +112 -0
- package/dist/local/mcp.js +548 -0
- package/dist/local/operations.js +1749 -0
- package/dist/local/pack/coverageReport.js +192 -0
- package/dist/local/pack/exporter.js +195 -0
- package/dist/local/pack/schema.js +128 -0
- package/dist/local/pack/summary.js +127 -0
- package/dist/local/pack/validate.js +25 -0
- package/dist/local/proofRunnability.js +366 -0
- package/dist/local/recipe/dbSqljs.js +255 -0
- package/dist/local/reprove/paths.js +13 -0
- package/dist/local/reprove/scoped.js +136 -0
- package/dist/local/resolve/barrelWalker.js +178 -0
- package/dist/local/resolve/exportIndex.js +270 -0
- package/dist/local/resolve/importGraph.js +347 -0
- package/dist/local/resolve/resolver.js +122 -0
- package/dist/local/resolve/resolverCache.js +117 -0
- package/dist/local/rtm.js +413 -0
- package/dist/local/score/coverage.js +99 -0
- package/dist/local/score/doctor.js +67 -0
- package/dist/local/score/risk.js +362 -0
- package/dist/local/score/score.js +182 -0
- package/dist/local/types.js +1 -0
- package/dist/local/util/hash.js +16 -0
- package/dist/local/util/ids.js +16 -0
- package/dist/local/util/progress.js +8 -0
- package/dist/local/util/redact.js +39 -0
- package/dist/local/util/time.js +1 -0
- package/dist/local/util/walk.js +174 -0
- package/dist/local/viz/behaviorReportData.js +367 -0
- package/dist/local/viz/behaviorReportHtml.js +664 -0
- package/dist/local/viz/d3.bundle.js +3 -0
- package/dist/local/viz/html.js +1152 -0
- package/dist/local/viz/payload.js +525 -0
- package/dist/local/workspace.js +99 -0
- package/docs/agent-workflow.md +167 -0
- package/docs/agents/claude-code.md +43 -0
- package/docs/agents/codex.md +52 -0
- package/docs/agents/cursor.md +39 -0
- package/docs/agents/opencode.md +43 -0
- package/docs/agents/vscode.md +34 -0
- package/docs/local-proof-kit.md +269 -0
- package/package.json +92 -0
- package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
- package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
- package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
- package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
- package/scripts/spikes/failure-summary.mjs +29 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const ENTRY_PATH_RE = /(^|\/)(routes?|controllers?|handlers?|jobs?|workers?|processors?|queues?|consumers?|subscribers?|listeners?|server|cmd)\//i;
|
|
3
|
+
const ENTRY_FILE_RE = /(^|\/)[^/]*(controller|handler|route|router|job|processor|worker|queue|consumer|subscriber|listener|command|gateway)\.[^.\/]+$/i;
|
|
4
|
+
const API_HANDLER_NAME_RE = /^(GET|POST|PUT|PATCH|DELETE|handle.*|handler|route|controller|endpoint)$/i;
|
|
5
|
+
const ENTRY_NAME_RE = /(^|[.#])(main|serve|request|endpoint)/i;
|
|
6
|
+
function symbolFile(n) {
|
|
7
|
+
if (typeof n.properties.file === "string")
|
|
8
|
+
return n.properties.file;
|
|
9
|
+
return n.external_id.replace(/^sym:/, "").split("#")[0];
|
|
10
|
+
}
|
|
11
|
+
function symbolTitle(n) {
|
|
12
|
+
return n.title ?? n.external_id.split("#")[1] ?? n.external_id;
|
|
13
|
+
}
|
|
14
|
+
function confirmedBehaviorIds(graph) {
|
|
15
|
+
const ids = new Set();
|
|
16
|
+
const nodeKinds = new Map(graph.nodes.map((n) => [n.external_id, n.kind]));
|
|
17
|
+
for (const e of graph.edges) {
|
|
18
|
+
if (e.evidence_strength !== "hard")
|
|
19
|
+
continue;
|
|
20
|
+
if (e.relationship_type !== "TESTED_BY" && e.relationship_type !== "COVERS")
|
|
21
|
+
continue;
|
|
22
|
+
if (nodeKinds.get(e.from_external_id) === "CodeSymbol" || nodeKinds.get(e.from_external_id) === "Requirement")
|
|
23
|
+
ids.add(e.from_external_id);
|
|
24
|
+
if (nodeKinds.get(e.to_external_id) === "CodeSymbol" || nodeKinds.get(e.to_external_id) === "Requirement")
|
|
25
|
+
ids.add(e.to_external_id);
|
|
26
|
+
}
|
|
27
|
+
return ids;
|
|
28
|
+
}
|
|
29
|
+
const GIT_CHURN_BATCH = 200;
|
|
30
|
+
function gitChurn(root, files, window) {
|
|
31
|
+
const out = new Map();
|
|
32
|
+
if (!root || files.length === 0)
|
|
33
|
+
return out;
|
|
34
|
+
for (let i = 0; i < files.length; i += GIT_CHURN_BATCH) {
|
|
35
|
+
const batch = files.slice(i, i + GIT_CHURN_BATCH);
|
|
36
|
+
try {
|
|
37
|
+
const stdout = execFileSync("git", ["log", `--since=${window}`, "--numstat", "--", ...batch], {
|
|
38
|
+
cwd: root,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
timeout: 4000,
|
|
42
|
+
maxBuffer: 2_000_000
|
|
43
|
+
});
|
|
44
|
+
for (const line of stdout.split("\n")) {
|
|
45
|
+
const m = line.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/);
|
|
46
|
+
if (!m)
|
|
47
|
+
continue;
|
|
48
|
+
const adds = m[1] === "-" ? 0 : Number(m[1]);
|
|
49
|
+
const dels = m[2] === "-" ? 0 : Number(m[2]);
|
|
50
|
+
out.set(m[3], (out.get(m[3]) ?? 0) + adds + dels);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function gitFirstCommitBatch(root, files) {
|
|
60
|
+
const out = new Map();
|
|
61
|
+
if (!root || files.length === 0)
|
|
62
|
+
return out;
|
|
63
|
+
for (let i = 0; i < files.length; i += GIT_CHURN_BATCH) {
|
|
64
|
+
const batch = files.slice(i, i + GIT_CHURN_BATCH);
|
|
65
|
+
try {
|
|
66
|
+
const stdout = execFileSync("git", ["log", "--diff-filter=A", "--reverse", "--format=format:%ct", "--name-only", "--", ...batch], {
|
|
67
|
+
cwd: root,
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
timeout: 4000,
|
|
71
|
+
maxBuffer: 2_000_000
|
|
72
|
+
});
|
|
73
|
+
let currentTs = 0;
|
|
74
|
+
for (const line of stdout.split("\n")) {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (trimmed === "") {
|
|
77
|
+
currentTs = 0;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const ts = Number(trimmed);
|
|
81
|
+
if (!Number.isNaN(ts) && String(ts) === trimmed) {
|
|
82
|
+
currentTs = ts;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (currentTs > 0 && !out.has(trimmed)) {
|
|
86
|
+
out.set(trimmed, currentTs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function countOutgoingCalls(symbolId, graph) {
|
|
97
|
+
const targets = new Set();
|
|
98
|
+
for (const e of graph.edges) {
|
|
99
|
+
if (e.relationship_type === "CALLS" && e.from_external_id === symbolId) {
|
|
100
|
+
targets.add(e.to_external_id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return targets.size;
|
|
104
|
+
}
|
|
105
|
+
function isEntryPoint(node) {
|
|
106
|
+
const file = symbolFile(node);
|
|
107
|
+
const title = symbolTitle(node);
|
|
108
|
+
if (API_HANDLER_NAME_RE.test(title) && /(^|\/)api(s)?\//i.test(file))
|
|
109
|
+
return true;
|
|
110
|
+
return ENTRY_PATH_RE.test(file) || ENTRY_FILE_RE.test(file) || ENTRY_NAME_RE.test(`${file}#${title}`);
|
|
111
|
+
}
|
|
112
|
+
function isHttpRouteSymbol(node) {
|
|
113
|
+
return /^(GET|POST|PUT|PATCH|DELETE)$/i.test(symbolTitle(node)) && /(^|\/)api(s)?\//i.test(symbolFile(node));
|
|
114
|
+
}
|
|
115
|
+
function deriveRouteWeight(node) {
|
|
116
|
+
const file = symbolFile(node);
|
|
117
|
+
const title = symbolTitle(node);
|
|
118
|
+
const text = `${file} ${title}`;
|
|
119
|
+
const methodMatch = text.match(/\b(POST|GET|PUT|DELETE|PATCH)\b/i);
|
|
120
|
+
const method = methodMatch?.[1].toUpperCase() ?? "";
|
|
121
|
+
const isStore = /\/store\//i.test(file) || /\/store\b/i.test(file);
|
|
122
|
+
const isAdmin = /\/admin\//i.test(file) || /\/admin\b/i.test(file);
|
|
123
|
+
if (isHttpRouteSymbol(node) && isStore) {
|
|
124
|
+
if (method === "POST")
|
|
125
|
+
return 10;
|
|
126
|
+
if (method === "DELETE")
|
|
127
|
+
return 9;
|
|
128
|
+
if (method === "PUT")
|
|
129
|
+
return 8;
|
|
130
|
+
if (method === "GET")
|
|
131
|
+
return 5;
|
|
132
|
+
// default store route mutation-ish weight
|
|
133
|
+
return 7;
|
|
134
|
+
}
|
|
135
|
+
if (isHttpRouteSymbol(node) && isAdmin) {
|
|
136
|
+
if (method === "POST")
|
|
137
|
+
return 6;
|
|
138
|
+
if (method === "GET")
|
|
139
|
+
return 3;
|
|
140
|
+
return 5;
|
|
141
|
+
}
|
|
142
|
+
if (isEntryPoint(node))
|
|
143
|
+
return 4;
|
|
144
|
+
if (/(^|\/)(services?|controllers?|handlers?|modules?)\//i.test(file))
|
|
145
|
+
return 4;
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
function deriveDataSensitivity(node) {
|
|
149
|
+
const text = `${node.external_id} ${symbolFile(node)} ${symbolTitle(node)}`.toLowerCase();
|
|
150
|
+
const tiers = [
|
|
151
|
+
[/payment|stripe|capture|refund|charge|billing/, 10],
|
|
152
|
+
[/auth|token|session|password|credential|jwt|oauth/, 9],
|
|
153
|
+
[/order|cart|checkout|invoice|transaction/, 7],
|
|
154
|
+
[/customer|user|account|profile|pii|gdpr/, 6],
|
|
155
|
+
[/notification|email|sms|webhook|push/, 3]
|
|
156
|
+
];
|
|
157
|
+
for (const [re, weight] of tiers) {
|
|
158
|
+
if (re.test(text))
|
|
159
|
+
return weight;
|
|
160
|
+
}
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
function getFlowDepth(node, graph) {
|
|
164
|
+
const entryIds = new Set(graph.nodes.filter((n) => n.kind === "CodeSymbol" && isEntryPoint(n)).map((n) => n.external_id));
|
|
165
|
+
if (entryIds.has(node.external_id))
|
|
166
|
+
return 0;
|
|
167
|
+
// BFS backward over CALLS edges to find nearest entry point.
|
|
168
|
+
const callers = new Map();
|
|
169
|
+
for (const e of graph.edges) {
|
|
170
|
+
if (e.relationship_type === "CALLS") {
|
|
171
|
+
const set = callers.get(e.to_external_id) ?? new Set();
|
|
172
|
+
set.add(e.from_external_id);
|
|
173
|
+
callers.set(e.to_external_id, set);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let depth = 0;
|
|
177
|
+
let frontier = new Set(callers.get(node.external_id) ?? []);
|
|
178
|
+
const seen = new Set(frontier);
|
|
179
|
+
while (frontier.size > 0 && depth < 6) {
|
|
180
|
+
depth++;
|
|
181
|
+
for (const id of frontier) {
|
|
182
|
+
if (entryIds.has(id))
|
|
183
|
+
return depth;
|
|
184
|
+
}
|
|
185
|
+
const next = new Set();
|
|
186
|
+
for (const id of frontier) {
|
|
187
|
+
for (const caller of callers.get(id) ?? []) {
|
|
188
|
+
if (!seen.has(caller)) {
|
|
189
|
+
seen.add(caller);
|
|
190
|
+
next.add(caller);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
frontier = next;
|
|
195
|
+
}
|
|
196
|
+
return depth >= 6 ? 6 : depth;
|
|
197
|
+
}
|
|
198
|
+
function complexityProxy(node) {
|
|
199
|
+
const start = typeof node.properties.start_line === "number" ? node.properties.start_line : 0;
|
|
200
|
+
const end = typeof node.properties.end_line === "number" ? node.properties.end_line : 0;
|
|
201
|
+
if (start > 0 && end >= start)
|
|
202
|
+
return end - start + 1;
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
function normalizeScores(values) {
|
|
206
|
+
const min = Math.min(...values);
|
|
207
|
+
const max = Math.max(...values);
|
|
208
|
+
if (min === max) {
|
|
209
|
+
return values.map(() => 5);
|
|
210
|
+
}
|
|
211
|
+
return values.map((v) => 1 + ((v - min) / (max - min)) * 9);
|
|
212
|
+
}
|
|
213
|
+
const DETECTION_MAP = {
|
|
214
|
+
proven: 1,
|
|
215
|
+
associated: 5,
|
|
216
|
+
none: 10
|
|
217
|
+
};
|
|
218
|
+
const NEW_CODE_DAYS = 30;
|
|
219
|
+
const NEW_CODE_SECONDS = NEW_CODE_DAYS * 24 * 60 * 60;
|
|
220
|
+
function computeRawORS(node, graph, incomingRefs, gitChurn, fanOut, detectionTier, firstCommitTs, nowSec) {
|
|
221
|
+
const isNew = firstCommitTs > 0 && nowSec - firstCommitTs < NEW_CODE_SECONDS;
|
|
222
|
+
const complexity = complexityProxy(node);
|
|
223
|
+
const rawP = gitChurn * 0.35 + fanOut * 0.3 + (isNew ? 15 : 0) + complexity * 0.2;
|
|
224
|
+
const routeWeight = deriveRouteWeight(node);
|
|
225
|
+
const flowDepth = getFlowDepth(node, graph);
|
|
226
|
+
const flowPosition = Math.max(0, 5 - flowDepth);
|
|
227
|
+
const dataSensitivity = deriveDataSensitivity(node);
|
|
228
|
+
const rawI = incomingRefs * 0.3 + routeWeight * 0.3 + flowPosition * 0.2 + dataSensitivity * 0.2;
|
|
229
|
+
const d = DETECTION_MAP[detectionTier];
|
|
230
|
+
return { p: rawP, i: rawI, d };
|
|
231
|
+
}
|
|
232
|
+
function associatedBehaviorIds(graph, candidateIds) {
|
|
233
|
+
const ids = new Set();
|
|
234
|
+
for (const e of graph.candidate_edges ?? []) {
|
|
235
|
+
if (e.relationship_type !== "MAY_BE_TESTED_BY" && e.relationship_type !== "MAY_COVER" && e.relationship_type !== "MAY_RELATE_TO") {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// AI suggestions are useful prompts, but they are never evidence. Do not let them
|
|
239
|
+
// lower detection difficulty in "what to test first" rankings.
|
|
240
|
+
if (e.review_status === "ai_suggested")
|
|
241
|
+
continue;
|
|
242
|
+
if (candidateIds.has(e.from_external_id))
|
|
243
|
+
ids.add(e.from_external_id);
|
|
244
|
+
if (candidateIds.has(e.to_external_id))
|
|
245
|
+
ids.add(e.to_external_id);
|
|
246
|
+
}
|
|
247
|
+
return ids;
|
|
248
|
+
}
|
|
249
|
+
export function rankRiskGaps(graph, opts = {}) {
|
|
250
|
+
const limit = opts.limit ?? 20;
|
|
251
|
+
const confirmed = confirmedBehaviorIds(graph);
|
|
252
|
+
const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && !confirmed.has(n.external_id));
|
|
253
|
+
const symbolIds = new Set(symbols.map((s) => s.external_id));
|
|
254
|
+
const symbolsByFile = new Map();
|
|
255
|
+
for (const s of symbols) {
|
|
256
|
+
const file = symbolFile(s);
|
|
257
|
+
const list = symbolsByFile.get(file);
|
|
258
|
+
if (list)
|
|
259
|
+
list.push(s);
|
|
260
|
+
else
|
|
261
|
+
symbolsByFile.set(file, [s]);
|
|
262
|
+
}
|
|
263
|
+
const files = [...new Set(symbols.map(symbolFile))];
|
|
264
|
+
const churn = gitChurn(opts.repoRoot ?? graph.workspace.root, files, opts.churnWindow ?? "180 days ago");
|
|
265
|
+
const firstCommitTs = gitFirstCommitBatch(opts.repoRoot ?? graph.workspace.root, files);
|
|
266
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
267
|
+
const incoming = new Map();
|
|
268
|
+
for (const e of graph.edges) {
|
|
269
|
+
if (e.relationship_type === "CALLS" && symbolIds.has(e.to_external_id)) {
|
|
270
|
+
incoming.set(e.to_external_id, (incoming.get(e.to_external_id) ?? 0) + 1);
|
|
271
|
+
}
|
|
272
|
+
else if (e.relationship_type === "IMPORTS") {
|
|
273
|
+
for (const s of symbolsByFile.get(e.to_external_id) ?? [])
|
|
274
|
+
incoming.set(s.external_id, (incoming.get(s.external_id) ?? 0) + 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const entryPoint = new Map(symbols.map((s) => [s.external_id, isEntryPoint(s)]));
|
|
278
|
+
const fanOut = new Map(symbols.map((s) => [s.external_id, countOutgoingCalls(s.external_id, graph)]));
|
|
279
|
+
const associated = associatedBehaviorIds(graph, symbolIds);
|
|
280
|
+
if (opts.legacy) {
|
|
281
|
+
return symbols
|
|
282
|
+
.map((s) => {
|
|
283
|
+
const file = symbolFile(s);
|
|
284
|
+
const incoming_refs = incoming.get(s.external_id) ?? 0;
|
|
285
|
+
const git_churn = churn.get(file) ?? 0;
|
|
286
|
+
const isEntry = entryPoint.get(s.external_id) ?? false;
|
|
287
|
+
const churnForScore = Math.min(git_churn, 500);
|
|
288
|
+
const score = Math.round((incoming_refs * 0.4 + churnForScore * 0.4 + (isEntry ? 20 : 0)) * 10) / 10;
|
|
289
|
+
const reasons = [
|
|
290
|
+
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}`,
|
|
291
|
+
`${git_churn} git churn line${git_churn === 1 ? "" : "s"} in 180 days${git_churn > 500 ? " (score capped at 500)" : ""}`
|
|
292
|
+
];
|
|
293
|
+
if (isEntry)
|
|
294
|
+
reasons.push("near an API/route/handler entry point");
|
|
295
|
+
return { id: s.external_id, title: s.title || s.external_id, file, risk_score: score, incoming_refs, git_churn, entry_point: isEntry, reasons };
|
|
296
|
+
})
|
|
297
|
+
.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))
|
|
298
|
+
.slice(0, limit);
|
|
299
|
+
}
|
|
300
|
+
const rawScores = symbols.map((s) => {
|
|
301
|
+
const file = symbolFile(s);
|
|
302
|
+
const incoming_refs = incoming.get(s.external_id) ?? 0;
|
|
303
|
+
const git_churn = churn.get(file) ?? 0;
|
|
304
|
+
const fan_out = fanOut.get(s.external_id) ?? 0;
|
|
305
|
+
const ts = firstCommitTs.get(file) ?? 0;
|
|
306
|
+
const detectionTier = associated.has(s.external_id) ? "associated" : "none";
|
|
307
|
+
return computeRawORS(s, graph, incoming_refs, git_churn, fan_out, detectionTier, ts, nowSec);
|
|
308
|
+
});
|
|
309
|
+
const pScores = normalizeScores(rawScores.map((r) => r.p));
|
|
310
|
+
const iScores = normalizeScores(rawScores.map((r) => r.i));
|
|
311
|
+
return symbols
|
|
312
|
+
.map((s, idx) => {
|
|
313
|
+
const file = symbolFile(s);
|
|
314
|
+
const incoming_refs = incoming.get(s.external_id) ?? 0;
|
|
315
|
+
const git_churn = churn.get(file) ?? 0;
|
|
316
|
+
const fan_out = fanOut.get(s.external_id) ?? 0;
|
|
317
|
+
const isEntry = entryPoint.get(s.external_id) ?? false;
|
|
318
|
+
const route_weight = deriveRouteWeight(s);
|
|
319
|
+
const data_sensitivity = deriveDataSensitivity(s);
|
|
320
|
+
const flow_position = Math.max(0, 5 - getFlowDepth(s, graph));
|
|
321
|
+
const complexity_proxy = complexityProxy(s);
|
|
322
|
+
const firstTs = firstCommitTs.get(file) ?? 0;
|
|
323
|
+
const is_new_code = firstTs > 0 && nowSec - firstTs < NEW_CODE_SECONDS;
|
|
324
|
+
const p = Math.round(pScores[idx]);
|
|
325
|
+
const i = Math.round(iScores[idx]);
|
|
326
|
+
const d = rawScores[idx].d;
|
|
327
|
+
const detectionTier = associated.has(s.external_id) ? "associated" : "none";
|
|
328
|
+
const score = p * i * d;
|
|
329
|
+
const reasons = [
|
|
330
|
+
`ORS ${score} = P${p} × I${i} × D${d}`,
|
|
331
|
+
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}`,
|
|
332
|
+
`${git_churn} git churn line${git_churn === 1 ? "" : "s"} in 180 days`,
|
|
333
|
+
`route weight ${route_weight}, data sensitivity ${data_sensitivity}, fan-out ${fan_out}`
|
|
334
|
+
];
|
|
335
|
+
if (isEntry)
|
|
336
|
+
reasons.push("near an API/route/handler entry point");
|
|
337
|
+
if (is_new_code)
|
|
338
|
+
reasons.push("new code (< 30 days)");
|
|
339
|
+
return {
|
|
340
|
+
id: s.external_id,
|
|
341
|
+
title: s.title || s.external_id,
|
|
342
|
+
file,
|
|
343
|
+
risk_score: score,
|
|
344
|
+
incoming_refs,
|
|
345
|
+
git_churn,
|
|
346
|
+
entry_point: isEntry,
|
|
347
|
+
reasons,
|
|
348
|
+
probability: p,
|
|
349
|
+
impact: i,
|
|
350
|
+
detection_difficulty: d,
|
|
351
|
+
fan_out,
|
|
352
|
+
route_weight,
|
|
353
|
+
data_sensitivity,
|
|
354
|
+
flow_position,
|
|
355
|
+
complexity_proxy,
|
|
356
|
+
is_new_code,
|
|
357
|
+
integration_signal: detectionTier
|
|
358
|
+
};
|
|
359
|
+
})
|
|
360
|
+
.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))
|
|
361
|
+
.slice(0, limit);
|
|
362
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { denominatorBehaviors, denominatorComposition, nodesByKind } from "../graph/factories.js";
|
|
2
|
+
/**
|
|
3
|
+
* Graph readiness score. This is a *readiness* signal, not a proof of test-lift.
|
|
4
|
+
*
|
|
5
|
+
* Internal scoring weights and the exact formula live here and are NEVER
|
|
6
|
+
* exported in the pack. Only the 0..1 per-dimension breakdown, the overall
|
|
7
|
+
* 0..100 score, the band, and plain-language missing-evidence are exposed.
|
|
8
|
+
*/
|
|
9
|
+
const WEIGHTS = {
|
|
10
|
+
behavior_anchors: 0.22,
|
|
11
|
+
acceptance_criteria: 0.2,
|
|
12
|
+
provenance: 0.15,
|
|
13
|
+
interface_mapping: 0.15,
|
|
14
|
+
validation_evidence: 0.18,
|
|
15
|
+
known_regressions: 0.1
|
|
16
|
+
};
|
|
17
|
+
const saturate = (count, target) => Math.max(0, Math.min(1, count / target));
|
|
18
|
+
function bandFor(overall) {
|
|
19
|
+
if (overall >= 75)
|
|
20
|
+
return "strong";
|
|
21
|
+
if (overall >= 60)
|
|
22
|
+
return "good";
|
|
23
|
+
if (overall >= 40)
|
|
24
|
+
return "usable";
|
|
25
|
+
return "thin";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* ONE O(edges) pass instead of O(behaviors × edges): the denominator can be
|
|
29
|
+
* tens of thousands of code exports (Mattermost: ~20k eligible over ~25k
|
|
30
|
+
* edges), and per-behavior linear scans made `opro score` multi-second.
|
|
31
|
+
*/
|
|
32
|
+
function buildEvidenceIndex(graph) {
|
|
33
|
+
const hardTested = new Set();
|
|
34
|
+
const acOwners = new Set();
|
|
35
|
+
for (const e of graph.edges) {
|
|
36
|
+
if (e.relationship_type === "TESTED_BY" || e.relationship_type === "COVERS") {
|
|
37
|
+
hardTested.add(e.from_external_id);
|
|
38
|
+
hardTested.add(e.to_external_id);
|
|
39
|
+
}
|
|
40
|
+
else if (e.relationship_type === "HAS_ACCEPTANCE_CRITERION") {
|
|
41
|
+
acOwners.add(e.from_external_id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const weakTested = new Set();
|
|
45
|
+
const testLinkedFiles = new Set();
|
|
46
|
+
for (const e of graph.candidate_edges) {
|
|
47
|
+
if (e.relationship_type === "MAY_BE_TESTED_BY" || e.relationship_type === "MAY_COVER") {
|
|
48
|
+
weakTested.add(e.from_external_id);
|
|
49
|
+
weakTested.add(e.to_external_id);
|
|
50
|
+
}
|
|
51
|
+
else if (e.relationship_type === "MAY_RELATE_TO") {
|
|
52
|
+
testLinkedFiles.add(e.from_external_id);
|
|
53
|
+
testLinkedFiles.add(e.to_external_id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { hardTested, weakTested, testLinkedFiles, acOwners };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Test evidence for one behavior. Code exports get honest WEAK credit when a
|
|
60
|
+
* test resolved-imports their file (the Phase-2 MAY_RELATE_TO links) —
|
|
61
|
+
* symbol-level confirmation is Phase 4's job; without this interim credit,
|
|
62
|
+
* code-derived behaviors would be structurally uncoverable and a well-tested
|
|
63
|
+
* code-only repo would score "thin" no matter what.
|
|
64
|
+
*/
|
|
65
|
+
function evidenceFor(b, idx) {
|
|
66
|
+
if (idx.hardTested.has(b.external_id))
|
|
67
|
+
return "hard";
|
|
68
|
+
if (idx.weakTested.has(b.external_id))
|
|
69
|
+
return "weak";
|
|
70
|
+
if (b.behavior_source === "code_export") {
|
|
71
|
+
const file = typeof b.properties.file === "string" ? b.properties.file : "";
|
|
72
|
+
if (file && idx.testLinkedFiles.has(file))
|
|
73
|
+
return "weak";
|
|
74
|
+
}
|
|
75
|
+
return "none";
|
|
76
|
+
}
|
|
77
|
+
export function scoreGraph(graph) {
|
|
78
|
+
// Gate 3: the denominator is the SOLE basis for behavior counting here.
|
|
79
|
+
// Test-inferred flows are inventoried in the composition but contribute 0.
|
|
80
|
+
const behaviors = denominatorBehaviors(graph);
|
|
81
|
+
const denominator = denominatorComposition(graph);
|
|
82
|
+
const totalBehaviors = behaviors.length;
|
|
83
|
+
const strongBehaviors = behaviors.filter((b) => b.evidence_strength === "hard" || b.evidence_strength === "reviewed");
|
|
84
|
+
const weakBehaviors = behaviors.filter((b) => b.evidence_strength === "candidate" || b.evidence_strength === "weak");
|
|
85
|
+
const idx = buildEvidenceIndex(graph);
|
|
86
|
+
const explicitRequirements = denominator.requirement_template + denominator.markdown_requirement;
|
|
87
|
+
// behavior_anchors — code exports are "hard" evidence by construction, so a
|
|
88
|
+
// bare repo with six exported functions must NOT peg this at 1.0. The cap
|
|
89
|
+
// SCALES with explicit requirements rather than vanishing at the first one:
|
|
90
|
+
// two incidental markdown headings on a 1,400-export repo flipped this from
|
|
91
|
+
// 0.5-capped to a perfect score in live dogfooding (knife-edge).
|
|
92
|
+
let behaviorScore = saturate(strongBehaviors.length + weakBehaviors.length * 0.4, 6);
|
|
93
|
+
behaviorScore = Math.min(behaviorScore, 0.5 + 0.5 * saturate(explicitRequirements, 8));
|
|
94
|
+
// acceptance_criteria — ACs attach to REQUIREMENTS. Judging code exports for
|
|
95
|
+
// lacking ACs would zero this dim on any sizeable codebase; symbol-level
|
|
96
|
+
// coverage semantics arrive in Phase 4.
|
|
97
|
+
const acNodes = nodesByKind(graph, "AcceptanceCriterion").length;
|
|
98
|
+
const requirementBehaviors = behaviors.filter((b) => b.behavior_source === "requirement_template" || b.behavior_source === "markdown_requirement");
|
|
99
|
+
const behaviorsWithAc = requirementBehaviors.filter((b) => idx.acOwners.has(b.external_id)).length;
|
|
100
|
+
const acScore = requirementBehaviors.length === 0
|
|
101
|
+
? saturate(acNodes, 4) * 0.5
|
|
102
|
+
: 0.7 * (behaviorsWithAc / requirementBehaviors.length) + 0.3 * saturate(acNodes, 4);
|
|
103
|
+
// provenance
|
|
104
|
+
const totalNodes = graph.nodes.length || 1;
|
|
105
|
+
const nodesWithRef = graph.nodes.filter((n) => Boolean(n.provenance?.source_ref)).length;
|
|
106
|
+
const provenanceScore = nodesWithRef / totalNodes;
|
|
107
|
+
// interface_mapping
|
|
108
|
+
const services = nodesByKind(graph, "Service");
|
|
109
|
+
const endpoints = nodesByKind(graph, "Endpoint");
|
|
110
|
+
const strongIface = [...services, ...endpoints].filter((n) => n.evidence_strength === "hard" || n.evidence_strength === "reviewed").length;
|
|
111
|
+
const weakIface = [...services, ...endpoints].filter((n) => n.evidence_strength === "candidate" || n.evidence_strength === "weak").length;
|
|
112
|
+
const ifaceEdges = graph.candidate_edges.filter((e) => e.relationship_type === "MAY_REQUIRE_INTERFACE").length;
|
|
113
|
+
const interfaceScore = saturate(strongIface + weakIface * 0.4 + ifaceEdges * 0.5, 5);
|
|
114
|
+
// validation_evidence
|
|
115
|
+
const testCaseCount = nodesByKind(graph, "TestCase").length;
|
|
116
|
+
const coveredBehaviors = behaviors.reduce((acc, b) => {
|
|
117
|
+
const ev = evidenceFor(b, idx);
|
|
118
|
+
return acc + (ev === "hard" ? 1 : ev === "weak" ? 0.5 : 0);
|
|
119
|
+
}, 0);
|
|
120
|
+
const coverageFrac = totalBehaviors === 0 ? 0 : coveredBehaviors / totalBehaviors;
|
|
121
|
+
const validationScore = 0.6 * coverageFrac + 0.4 * saturate(testCaseCount, 8);
|
|
122
|
+
// known_regressions
|
|
123
|
+
const incidents = nodesByKind(graph, "Incident").length;
|
|
124
|
+
const regressionScore = saturate(incidents, 3);
|
|
125
|
+
const breakdown = {
|
|
126
|
+
behavior_anchors: round2(behaviorScore),
|
|
127
|
+
acceptance_criteria: round2(acScore),
|
|
128
|
+
provenance: round2(provenanceScore),
|
|
129
|
+
interface_mapping: round2(interfaceScore),
|
|
130
|
+
validation_evidence: round2(validationScore),
|
|
131
|
+
known_regressions: round2(regressionScore)
|
|
132
|
+
};
|
|
133
|
+
const overall = Math.round(100 *
|
|
134
|
+
(WEIGHTS.behavior_anchors * breakdown.behavior_anchors +
|
|
135
|
+
WEIGHTS.acceptance_criteria * breakdown.acceptance_criteria +
|
|
136
|
+
WEIGHTS.provenance * breakdown.provenance +
|
|
137
|
+
WEIGHTS.interface_mapping * breakdown.interface_mapping +
|
|
138
|
+
WEIGHTS.validation_evidence * breakdown.validation_evidence +
|
|
139
|
+
WEIGHTS.known_regressions * breakdown.known_regressions));
|
|
140
|
+
const missing_evidence = missingEvidence(breakdown, denominator);
|
|
141
|
+
// A capped extraction means the denominator UNDERSTATES the repo's behavior
|
|
142
|
+
// surface — coverage % over a truncated denominator must say so.
|
|
143
|
+
if (graph.analysis?.symbol_cap_hit) {
|
|
144
|
+
missing_evidence.push("We hit the limit and only counted part of your code, so coverage looks lower than it really is. Raise ORANGEPRO_MAX_SYMBOLS and re-run `opro analyze .`.");
|
|
145
|
+
}
|
|
146
|
+
if (graph.analysis && graph.analysis.flows_truncated > 0) {
|
|
147
|
+
missing_evidence.push(`${graph.analysis.flows_truncated} test file(s) exceeded the inferred-behavior cap (${graph.analysis.max_inferred_flows}) and are not counted; raise ORANGEPRO_MAX_FLOWS to include them.`);
|
|
148
|
+
}
|
|
149
|
+
return { overall, band: bandFor(overall), breakdown, missing_evidence, denominator };
|
|
150
|
+
}
|
|
151
|
+
function missingEvidence(b, denominator) {
|
|
152
|
+
const out = [];
|
|
153
|
+
if (denominator.total === 0) {
|
|
154
|
+
out.push(denominator.excluded_test_inferred > 0
|
|
155
|
+
? `Nothing to measure coverage against: we found ${denominator.excluded_test_inferred} behavior(s) guessed from test names, but those don't count (a test can't prove its own requirement). Add a requirements list (CSV/markdown) or analyze code that has functions/classes.`
|
|
156
|
+
: "Nothing to measure coverage against: no countable behaviors found. Add a requirements list (CSV/markdown) or analyze code that has functions/classes.");
|
|
157
|
+
}
|
|
158
|
+
// Keyed on the COMPOSITION, not evidence strength: code exports are "hard"
|
|
159
|
+
// by construction and used to permanently suppress this nudge on the exact
|
|
160
|
+
// repos it was written for (code-only, zero requirements).
|
|
161
|
+
const explicit = denominator.requirement_template + denominator.markdown_requirement;
|
|
162
|
+
if (denominator.total > 0 && explicit === 0) {
|
|
163
|
+
out.push("Add a few written requirements (we only found code and test names so far) — a short CSV/markdown list, or acceptance criteria.");
|
|
164
|
+
}
|
|
165
|
+
else if (explicit > 0 && explicit < 5 && denominator.code_export >= explicit * 20) {
|
|
166
|
+
out.push(`Only ${explicit} written requirement(s) for ${denominator.total} behaviors — add a requirements list so coverage reflects what the app should do, not just how much code exists.`);
|
|
167
|
+
}
|
|
168
|
+
if (b.acceptance_criteria < 0.5)
|
|
169
|
+
out.push("Add acceptance criteria so behaviors can be tested with concrete checks.");
|
|
170
|
+
if (b.interface_mapping < 0.4)
|
|
171
|
+
out.push("Add API/screen/service mapping (OpenAPI or route docs) for more specific tests.");
|
|
172
|
+
if (b.validation_evidence < 0.4)
|
|
173
|
+
out.push("Link existing tests or add manual QA steps so we can tell what's already covered.");
|
|
174
|
+
if (b.known_regressions < 0.2)
|
|
175
|
+
out.push("Add known bugs/incidents to enable targeted regression tests.");
|
|
176
|
+
if (b.provenance < 0.6)
|
|
177
|
+
out.push("Add source links so each behavior can be traced back to real code.");
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function round2(n) {
|
|
181
|
+
return Math.round(n * 100) / 100;
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/** Content hash with the `sha256:` prefix used across graph + pack provenance. */
|
|
4
|
+
export function hashString(input) {
|
|
5
|
+
return "sha256:" + createHash("sha256").update(input).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function hashBuffer(buf) {
|
|
8
|
+
return "sha256:" + createHash("sha256").update(buf).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export function hashFile(absPath) {
|
|
11
|
+
return hashBuffer(readFileSync(absPath));
|
|
12
|
+
}
|
|
13
|
+
/** Short, stable, non-reversible digest for deriving node ids. */
|
|
14
|
+
export function shortHash(input) {
|
|
15
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { shortHash } from "./hash.js";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic internal node id. Stable across runs for the same (kind, key)
|
|
4
|
+
* so incremental updates can re-find and refresh existing nodes.
|
|
5
|
+
*/
|
|
6
|
+
export function stableId(kind, key) {
|
|
7
|
+
return `${kind}:${shortHash(`${kind}|${key}`)}`;
|
|
8
|
+
}
|
|
9
|
+
export function slugify(input) {
|
|
10
|
+
const slug = input
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
13
|
+
.replace(/^-+|-+$/g, "")
|
|
14
|
+
.slice(0, 64);
|
|
15
|
+
return slug || "item";
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for any text that may surface in local reports or quote
|
|
3
|
+
* provenance. Privacy default is metadata-only; even when a source excerpt is
|
|
4
|
+
* read in-process for generation, obvious secrets are scrubbed first.
|
|
5
|
+
*/
|
|
6
|
+
const SECRET_PATTERNS = [
|
|
7
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted:private-key>"],
|
|
8
|
+
[/sk-ant-[A-Za-z0-9_-]{16,}/g, "<redacted:anthropic-key>"],
|
|
9
|
+
[/sk-[A-Za-z0-9]{20,}/g, "<redacted:openai-key>"],
|
|
10
|
+
[/ghp_[A-Za-z0-9]{20,}/g, "<redacted:github-token>"],
|
|
11
|
+
[/gho_[A-Za-z0-9]{20,}/g, "<redacted:github-token>"],
|
|
12
|
+
[/AKIA[0-9A-Z]{16}/g, "<redacted:aws-access-key>"],
|
|
13
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/g, "<redacted:slack-token>"],
|
|
14
|
+
[/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "<redacted:jwt>"],
|
|
15
|
+
[/\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*["']?[^\s"']{8,}/gi, "<redacted:credential>"]
|
|
16
|
+
];
|
|
17
|
+
export function redactSecrets(text) {
|
|
18
|
+
let out = text;
|
|
19
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
20
|
+
out = out.replace(pattern, replacement);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function redactSecretsPreservingLineCount(text) {
|
|
25
|
+
let out = text;
|
|
26
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
27
|
+
out = out.replace(pattern, (match) => {
|
|
28
|
+
const newlineCount = match.match(/\r\n|\n|\r/g)?.length ?? 0;
|
|
29
|
+
return `${replacement}${"\n".repeat(newlineCount)}`;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export function containsSecret(text) {
|
|
35
|
+
return SECRET_PATTERNS.some(([pattern]) => {
|
|
36
|
+
pattern.lastIndex = 0;
|
|
37
|
+
return pattern.test(text);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const systemClock = () => new Date().toISOString();
|