@orangepro/orangepro-mcp 0.2.44 → 0.2.46
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 +322 -30
- package/dist/local/analyze/treeSitter/engine.js +1126 -90
- package/dist/local/autoProve.js +208 -102
- package/dist/local/operations.js +82 -18
- package/dist/local/proofDoctor.js +23 -3
- package/dist/local/provenance.js +2 -2
- package/dist/local/score/risk.js +339 -111
- package/dist/local/score/riskConfig.js +21 -1
- package/dist/local/viz/behaviorReportData.js +83 -20
- package/dist/local/viz/behaviorReportHtml.js +13 -2
- package/package.json +1 -1
- package/scripts/spikes/python-dynamic-proof-spike.mjs +399 -103
- package/scripts/spikes/python-mutate.py +72 -24
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 { readFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
3
7
|
import { hashString } from "../util/hash.js";
|
|
4
8
|
import { builtInRankExclusion } from "./rankEligibility.js";
|
|
5
9
|
/** Explicit scorer identity. Formula changes require a new value. */
|
|
@@ -74,13 +78,150 @@ function confirmedBehaviorIds(graph, provenIds) {
|
|
|
74
78
|
}
|
|
75
79
|
return ids;
|
|
76
80
|
}
|
|
77
|
-
|
|
81
|
+
/** Default bounds for repository-wide history acquisition (overridable via
|
|
82
|
+
* tuning.churn_max_commits / tuning.churn_timeout_seconds, always disclosed).
|
|
83
|
+
* Sized so an active monorepo's full 180-day window completes (a 5,000-commit /
|
|
84
|
+
* 60 s bound truncated windows that 0.2.44 scanned completely). */
|
|
85
|
+
export const GIT_CHURN_MAX_COMMITS = 20_000;
|
|
86
|
+
export const GIT_CHURN_TIMEOUT_MS = 300_000;
|
|
87
|
+
const GIT_FIRST_COMMIT_BATCH = 200;
|
|
88
|
+
/**
|
|
89
|
+
* This worker intentionally uses spawn, not execFileSync: stdout is parsed as it
|
|
90
|
+
* arrives, so a time bound returns the values already observed instead of erasing
|
|
91
|
+
* completed chunks. It keeps one counter per path the window touched (bounded by
|
|
92
|
+
* the window, not by total history) so a single scan serves every caller. The scoring API
|
|
93
|
+
* is synchronous, therefore the worker signals only after writing its result file.
|
|
94
|
+
*/
|
|
95
|
+
const GIT_CHURN_WORKER_SOURCE = String.raw `
|
|
96
|
+
const { spawn } = require("node:child_process");
|
|
97
|
+
const { writeFileSync } = require("node:fs");
|
|
98
|
+
const { workerData } = require("node:worker_threads");
|
|
99
|
+
const state = new Int32Array(workerData.signal);
|
|
100
|
+
const values = new Map();
|
|
101
|
+
const trackedFiles = Array.isArray(workerData.files) ? new Set(workerData.files) : null;
|
|
102
|
+
let commitsScanned = 0;
|
|
103
|
+
let commitLimitReached = false;
|
|
104
|
+
let expectingTimestamp = false;
|
|
105
|
+
let renameRecord = null;
|
|
106
|
+
let renameOldPath = null;
|
|
107
|
+
let remainder = "";
|
|
108
|
+
let stderr = "";
|
|
109
|
+
let done = false;
|
|
110
|
+
let timedOut = false;
|
|
111
|
+
let timer;
|
|
112
|
+
let child;
|
|
113
|
+
let childClosed = false;
|
|
114
|
+
const firstLine = (text) => String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
|
|
115
|
+
const add = (path, adds, dels) => {
|
|
116
|
+
if (!path || commitLimitReached || (trackedFiles && !trackedFiles.has(path))) return;
|
|
117
|
+
values.set(path, (values.get(path) || 0) + adds + dels);
|
|
118
|
+
};
|
|
119
|
+
const consume = (token) => {
|
|
120
|
+
if (renameRecord) {
|
|
121
|
+
// Git numstat -z represents a rename as an empty-path record followed by
|
|
122
|
+
// the old path and then the new path. Attribute churn to the current path.
|
|
123
|
+
if (renameOldPath === null) {
|
|
124
|
+
renameOldPath = token;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
add(token, renameRecord.adds, renameRecord.dels);
|
|
128
|
+
renameRecord = null;
|
|
129
|
+
renameOldPath = null;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (/^[0-9a-f]{40}$/i.test(token)) { expectingTimestamp = true; return; }
|
|
133
|
+
if (expectingTimestamp && /^\d+$/.test(token)) {
|
|
134
|
+
expectingTimestamp = false;
|
|
135
|
+
if (commitsScanned >= workerData.maxCommits) {
|
|
136
|
+
commitLimitReached = true;
|
|
137
|
+
if (child) child.kill("SIGTERM");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
commitsScanned++;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const match = token.replace(/^\n/, "").match(/^(-|\d+)\t(-|\d+)\t([\s\S]*)$/);
|
|
144
|
+
if (!match) return;
|
|
145
|
+
const adds = match[1] === "-" ? 0 : Number(match[1]);
|
|
146
|
+
const dels = match[2] === "-" ? 0 : Number(match[2]);
|
|
147
|
+
if (match[3] === "") { renameRecord = { adds, dels }; renameOldPath = null; return; }
|
|
148
|
+
add(match[3], adds, dels);
|
|
149
|
+
};
|
|
150
|
+
const finish = (stateName, reason) => {
|
|
151
|
+
if (done) return;
|
|
152
|
+
done = true;
|
|
153
|
+
if (timer) clearTimeout(timer);
|
|
154
|
+
try {
|
|
155
|
+
writeFileSync(workerData.resultPath, JSON.stringify({ state: stateName, reason, commitsScanned, values: [...values].sort(([a], [b]) => a.localeCompare(b)) }));
|
|
156
|
+
} finally {
|
|
157
|
+
Atomics.store(state, 0, 1);
|
|
158
|
+
Atomics.notify(state, 0);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const stopChild = (signal) => {
|
|
162
|
+
if (!child || childClosed) return;
|
|
163
|
+
try { child.kill(signal); } catch {}
|
|
164
|
+
};
|
|
165
|
+
// The parent terminates this worker after consuming the bounded handoff. Make
|
|
166
|
+
// sure an unresponsive git child cannot survive that termination.
|
|
167
|
+
process.on("exit", () => stopChild("SIGKILL"));
|
|
168
|
+
try {
|
|
169
|
+
child = spawn("git", ["log", "--no-merges", "--numstat", "--format=%H%x00%ct", "-z", "--since=" + workerData.window, "--max-count=" + (workerData.maxCommits + 1)], {
|
|
170
|
+
cwd: workerData.root,
|
|
171
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
finish("unavailable", error && error.code === "ENOENT" ? "git_missing" : "error:" + firstLine(error && error.message));
|
|
175
|
+
}
|
|
176
|
+
timer = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
stopChild("SIGTERM");
|
|
179
|
+
setTimeout(() => finish("partial", "timeout"), 100).unref();
|
|
180
|
+
}, workerData.timeoutMs);
|
|
181
|
+
if (child) {
|
|
182
|
+
child.stdout.on("data", (chunk) => {
|
|
183
|
+
remainder += chunk.toString("utf8");
|
|
184
|
+
let nul;
|
|
185
|
+
while ((nul = remainder.indexOf("\0")) >= 0) {
|
|
186
|
+
consume(remainder.slice(0, nul));
|
|
187
|
+
remainder = remainder.slice(nul + 1);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
child.stderr.on("data", (chunk) => { if (stderr.length < 4096) stderr += chunk.toString("utf8"); });
|
|
191
|
+
child.on("error", (error) => finish("unavailable", error && error.code === "ENOENT" ? "git_missing" : "error:" + firstLine(error && error.message)));
|
|
192
|
+
child.on("close", (code) => {
|
|
193
|
+
childClosed = true;
|
|
194
|
+
if (remainder) consume(remainder);
|
|
195
|
+
if (timedOut) return finish("partial", "timeout");
|
|
196
|
+
if (commitLimitReached) return finish("partial", "commit_limit");
|
|
197
|
+
if (code === 0) return finish(workerData.cloneState === "full" ? "complete" : "partial", workerData.cloneState === "shallow" ? "shallow clone: churn based on " + commitsScanned + " commits" : workerData.cloneState === "partial" ? "partial clone: churn based on " + commitsScanned + " commits" : undefined);
|
|
198
|
+
finish(commitsScanned > 0 ? "partial" : "unavailable", "error:" + (firstLine(stderr) || "git log exited " + code));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
const historyCache = new Map();
|
|
203
|
+
function firstErrorLine(error) {
|
|
204
|
+
const e = error;
|
|
205
|
+
const raw = e.stderr ? String(e.stderr) : e.message;
|
|
206
|
+
return raw.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "unknown Git error";
|
|
207
|
+
}
|
|
208
|
+
function unavailableHealth(root, churnWindow, error) {
|
|
209
|
+
const e = error;
|
|
210
|
+
const line = firstErrorLine(error);
|
|
211
|
+
const reason = e.code === "ENOENT"
|
|
212
|
+
? "git_missing"
|
|
213
|
+
: /not a git repository/i.test(line)
|
|
214
|
+
? "not_a_git_repo"
|
|
215
|
+
: `error:${line}`;
|
|
216
|
+
return { sourceRoot: root ?? null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow, churnState: "unavailable", churnAvailable: false, reason };
|
|
217
|
+
}
|
|
78
218
|
export function inspectRiskInputHealth(root, churnWindow) {
|
|
79
|
-
const
|
|
219
|
+
const configuredDays = loadRiskConfig(root ?? "").config.tuning.churn_window_days;
|
|
220
|
+
const unavailableWindow = churnWindow ?? `${configuredDays} days before HEAD`;
|
|
80
221
|
if (!root)
|
|
81
|
-
return { sourceRoot: null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnAvailable: false, reason: "
|
|
222
|
+
return { sourceRoot: null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnState: "unavailable", churnAvailable: false, reason: "not_a_git_repo" };
|
|
82
223
|
try {
|
|
83
|
-
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "
|
|
224
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 4000 }).trim();
|
|
84
225
|
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
85
226
|
const commitDate = execFileSync("git", ["show", "-s", "--format=%cI", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
86
227
|
const shallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() === "true";
|
|
@@ -94,7 +235,7 @@ export function inspectRiskInputHealth(root, churnWindow) {
|
|
|
94
235
|
// A normal full clone has no promisor-remote configuration.
|
|
95
236
|
}
|
|
96
237
|
const commitMs = Date.parse(commitDate);
|
|
97
|
-
const resolvedWindow = churnWindow ?? (Number.isFinite(commitMs) ? new Date(commitMs -
|
|
238
|
+
const resolvedWindow = churnWindow ?? (Number.isFinite(commitMs) ? new Date(commitMs - configuredDays * 24 * 60 * 60 * 1000).toISOString() : unavailableWindow);
|
|
98
239
|
return {
|
|
99
240
|
sourceRoot: root,
|
|
100
241
|
gitRoot,
|
|
@@ -102,59 +243,90 @@ export function inspectRiskInputHealth(root, churnWindow) {
|
|
|
102
243
|
commitDate,
|
|
103
244
|
history: shallow ? "shallow" : partial ? "partial" : "full",
|
|
104
245
|
churnWindow: resolvedWindow,
|
|
246
|
+
churnState: shallow || partial ? "partial" : "complete",
|
|
105
247
|
churnAvailable: !shallow && !partial,
|
|
106
248
|
reason: shallow
|
|
107
|
-
? "shallow
|
|
249
|
+
? "shallow clone: churn based on available history"
|
|
108
250
|
: partial
|
|
109
|
-
? "partial
|
|
251
|
+
? "partial clone: churn based on available history"
|
|
110
252
|
: undefined
|
|
111
253
|
};
|
|
112
254
|
}
|
|
113
|
-
catch {
|
|
114
|
-
return
|
|
255
|
+
catch (error) {
|
|
256
|
+
return unavailableHealth(root, unavailableWindow, error);
|
|
115
257
|
}
|
|
116
258
|
}
|
|
117
|
-
function gitChurn(root, files, window) {
|
|
118
|
-
const
|
|
119
|
-
if (!root ||
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
259
|
+
function gitChurn(root, files, window, health = inspectRiskInputHealth(root, window)) {
|
|
260
|
+
const empty = { values: new Map(), state: "unavailable", commitsScanned: 0, reason: health.reason };
|
|
261
|
+
if (!root || health.churnState === "unavailable")
|
|
262
|
+
return empty;
|
|
263
|
+
// Values are retained only for the requested paths, so that set and clone
|
|
264
|
+
// state are part of cache identity. A checkout can be marked partial at an
|
|
265
|
+
// unchanged HEAD and must then remain truthfully provisional.
|
|
266
|
+
const requested = new Set(files);
|
|
267
|
+
const tuning = loadRiskConfig(root).config.tuning;
|
|
268
|
+
const maxCommits = tuning.churn_max_commits ?? GIT_CHURN_MAX_COMMITS;
|
|
269
|
+
const timeoutMs = (tuning.churn_timeout_seconds ?? GIT_CHURN_TIMEOUT_MS / 1000) * 1000;
|
|
270
|
+
// ONE bounded scan per repository state: values are kept for every path the
|
|
271
|
+
// window touched and filtered per caller, so the ranking, the report header and
|
|
272
|
+
// the static snapshot never each pay for (and each time out on) the same scan.
|
|
273
|
+
const key = `${health.gitRoot ?? root}\0${health.commit ?? ""}\0${health.history}\0${window}\0${maxCommits}\0${timeoutMs}`;
|
|
274
|
+
const cached = historyCache.get(key);
|
|
275
|
+
if (cached)
|
|
276
|
+
return { ...cached, values: new Map([...cached.values].filter(([file]) => requested.has(file))) };
|
|
277
|
+
const dir = mkdtempSync(join(tmpdir(), "orangepro-churn-"));
|
|
278
|
+
const resultPath = join(dir, "result.json");
|
|
279
|
+
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
|
280
|
+
const state = new Int32Array(signal);
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
const worker = new Worker(GIT_CHURN_WORKER_SOURCE, {
|
|
284
|
+
eval: true,
|
|
285
|
+
workerData: {
|
|
286
|
+
root,
|
|
287
|
+
window,
|
|
288
|
+
files: null,
|
|
289
|
+
maxCommits,
|
|
290
|
+
timeoutMs,
|
|
291
|
+
cloneState: health.history,
|
|
292
|
+
resultPath,
|
|
293
|
+
signal
|
|
138
294
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
295
|
+
});
|
|
296
|
+
// The worker publishes a partial result 100ms after its child timeout; leave
|
|
297
|
+
// explicit scheduling room before the synchronous API forces it down.
|
|
298
|
+
Atomics.wait(state, 0, 0, timeoutMs + 2_000);
|
|
299
|
+
if (Atomics.load(state, 0) === 1)
|
|
300
|
+
parsed = JSON.parse(readFileSync(resultPath, "utf8"));
|
|
301
|
+
void worker.terminate().catch(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
parsed = { state: "unavailable", reason: `error:${firstErrorLine(error)}`, commitsScanned: 0, values: [] };
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
rmSync(dir, { recursive: true, force: true });
|
|
143
308
|
}
|
|
144
|
-
|
|
309
|
+
const full = parsed
|
|
310
|
+
? { state: parsed.state, reason: parsed.reason, commitsScanned: parsed.commitsScanned, values: new Map(parsed.values) }
|
|
311
|
+
: { state: "partial", reason: "timeout", commitsScanned: 0, values: new Map() };
|
|
312
|
+
historyCache.set(key, full);
|
|
313
|
+
// Defense in depth: a malformed worker payload may never score an unrequested path.
|
|
314
|
+
return { ...full, values: new Map([...full.values].filter(([file]) => requested.has(file))) };
|
|
145
315
|
}
|
|
316
|
+
/** Preserve the released ORS `is_new_code` input. This is separate from the
|
|
317
|
+
* recent-churn window because a file's first add may predate that window. */
|
|
146
318
|
function gitFirstCommitBatch(root, files) {
|
|
147
319
|
const out = new Map();
|
|
148
320
|
if (!root || files.length === 0)
|
|
149
321
|
return out;
|
|
150
|
-
for (let i = 0; i < files.length; i +=
|
|
151
|
-
const batch = files.slice(i, i +
|
|
322
|
+
for (let i = 0; i < files.length; i += GIT_FIRST_COMMIT_BATCH) {
|
|
323
|
+
const batch = files.slice(i, i + GIT_FIRST_COMMIT_BATCH);
|
|
152
324
|
try {
|
|
153
325
|
const stdout = execFileSync("git", ["log", "--diff-filter=A", "--reverse", "--format=format:%ct", "--name-only", "--", ...batch], {
|
|
154
326
|
cwd: root,
|
|
155
327
|
encoding: "utf8",
|
|
156
328
|
stdio: ["ignore", "pipe", "ignore"],
|
|
157
|
-
timeout:
|
|
329
|
+
timeout: 4_000,
|
|
158
330
|
maxBuffer: 2_000_000
|
|
159
331
|
});
|
|
160
332
|
let currentTs = 0;
|
|
@@ -169,30 +341,33 @@ function gitFirstCommitBatch(root, files) {
|
|
|
169
341
|
currentTs = ts;
|
|
170
342
|
continue;
|
|
171
343
|
}
|
|
172
|
-
if (currentTs > 0 && !out.has(trimmed))
|
|
344
|
+
if (currentTs > 0 && !out.has(trimmed))
|
|
173
345
|
out.set(trimmed, currentTs);
|
|
174
|
-
}
|
|
175
346
|
}
|
|
176
347
|
}
|
|
177
348
|
catch {
|
|
178
|
-
|
|
349
|
+
// Preserve the released fail-closed behavior for this optional ORS signal.
|
|
179
350
|
}
|
|
180
351
|
}
|
|
181
352
|
return out;
|
|
182
353
|
}
|
|
354
|
+
/** Repository-level acquisition metadata for report/provenance callers. */
|
|
355
|
+
export function inspectRiskChurn(root, files, churnWindow) {
|
|
356
|
+
const health = inspectRiskInputHealth(root, churnWindow);
|
|
357
|
+
const result = gitChurn(root, files, health.churnWindow, health);
|
|
358
|
+
return { state: result.state, reason: result.reason, commitsScanned: result.commitsScanned };
|
|
359
|
+
}
|
|
183
360
|
/** Exact deterministic identity of the Git-derived inputs consumed by ORS. */
|
|
184
361
|
export function riskHistoryFingerprint(root, files, churnWindow) {
|
|
185
362
|
const health = inspectRiskInputHealth(root, churnWindow);
|
|
186
363
|
const sortedFiles = [...new Set(files)].sort();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
const churn = gitChurn(root, sortedFiles, health.churnWindow);
|
|
191
|
-
const first = churn.complete ? gitFirstCommitBatch(root, sortedFiles) : new Map();
|
|
364
|
+
const churn = gitChurn(root, sortedFiles, health.churnWindow, health);
|
|
365
|
+
const first = churn.state === "complete" ? gitFirstCommitBatch(root, sortedFiles) : new Map();
|
|
192
366
|
return hashString(JSON.stringify({
|
|
193
|
-
state:
|
|
367
|
+
state: churn.state,
|
|
194
368
|
window: health.churnWindow,
|
|
195
|
-
|
|
369
|
+
reason: churn.reason,
|
|
370
|
+
commits_scanned: churn.commitsScanned,
|
|
196
371
|
churn: sortedFiles.map((file) => [file, churn.values.get(file) ?? 0]),
|
|
197
372
|
first_commit: sortedFiles.map((file) => [file, first.get(file) ?? 0])
|
|
198
373
|
}));
|
|
@@ -352,6 +527,10 @@ const IMPORTED_REPLACEMENT_CALL_RE = /^replace[A-Za-z0-9_]*$/i;
|
|
|
352
527
|
// removals (`pollers.Remove`, `RemoveSpeculativeWorkflowTaskTimeout`) are not.
|
|
353
528
|
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;
|
|
354
529
|
const PERSISTENCE_FIELD_RE = /(store|client|db|repo|repository|persistence|manager|queue|bucket|index|table|storage|dao)/i;
|
|
530
|
+
// The browser Web Storage API (`sessionStorage` / `localStorage`, optionally via
|
|
531
|
+
// `window.`) is a per-tab/per-device client cache, not a durable data store. Its
|
|
532
|
+
// `removeItem` matches the persistence vocabulary ("storage") by name only.
|
|
533
|
+
const BROWSER_WEB_STORAGE_FIELD_RE = /(^|\.)(sessionStorage|localStorage)$/;
|
|
355
534
|
const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(run|execute|handle|process|tick|scan)$/i;
|
|
356
535
|
const SCHEDULED_ENTRY_PATH_RE = /(^|\/)(jobs?|workers?|scanners?|scavengers?|cron|schedulers?|processors?|consumers?|reconcil\w*)(\/|$)/i;
|
|
357
536
|
/** A time- or queue-triggered entry: nothing calls it synchronously, nobody waits
|
|
@@ -446,8 +625,8 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
446
625
|
// worklist but do not rescale an unchanged peer.
|
|
447
626
|
const normalizationSymbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && rankEligible(n) && !isSuppressed(n.external_id));
|
|
448
627
|
const symbols = normalizationSymbols.filter((n) => !confirmed.has(n.external_id));
|
|
449
|
-
const worklistSymbols = symbols.filter((n) => n.properties.ranking_exclusion_reason !== "python_structural_container");
|
|
450
628
|
const symbolIds = new Set(normalizationSymbols.map((s) => s.external_id));
|
|
629
|
+
const fileBySymbolId = new Map(normalizationSymbols.map((s) => [s.external_id, symbolFile(s)]));
|
|
451
630
|
const symbolsByFile = new Map();
|
|
452
631
|
for (const s of normalizationSymbols) {
|
|
453
632
|
const file = symbolFile(s);
|
|
@@ -467,36 +646,60 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
467
646
|
const sensitivityIgnore = cfg.classification.sensitivity_ignore.map(globToRegExp);
|
|
468
647
|
const inputHealth = inspectRiskInputHealth(repoRoot, opts.churnWindow);
|
|
469
648
|
const churnWindow = inputHealth.churnWindow;
|
|
470
|
-
const churnResult =
|
|
649
|
+
const churnResult = gitChurn(repoRoot, files, churnWindow, inputHealth);
|
|
471
650
|
const churn = churnResult.values;
|
|
472
|
-
const churnAvailable =
|
|
651
|
+
const churnAvailable = churnResult.state === "complete";
|
|
473
652
|
const firstCommitTs = churnAvailable ? gitFirstCommitBatch(repoRoot, files) : new Map();
|
|
474
653
|
const commitMs = Date.parse(inputHealth.commitDate ?? "");
|
|
475
654
|
const graphMs = Date.parse(graph.updated_at || graph.created_at || "");
|
|
476
655
|
const nowSec = Math.floor((Number.isFinite(commitMs) ? commitMs : Number.isFinite(graphMs) ? graphMs : 0) / 1000);
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// ranking. Split the file's import count across its eligible symbols instead.
|
|
656
|
+
// A file-level IMPORTS edge does not reference any particular symbol. Only its
|
|
657
|
+
// AST-resolved named imports and non-call module.attribute references may be
|
|
658
|
+
// attributed; CALLS already has an exact symbol target. Older graphs without
|
|
659
|
+
// these metadata facts fail closed instead of redistributing imports.
|
|
482
660
|
const incoming = new Map();
|
|
483
|
-
|
|
661
|
+
// Display-only breakdown of the same total, so a reader can reconcile the
|
|
662
|
+
// number against the graph: CALLS edges + named imports + module.attr references.
|
|
663
|
+
const incomingParts = new Map();
|
|
664
|
+
const addIncoming = (id, kind, n) => {
|
|
665
|
+
incoming.set(id, (incoming.get(id) ?? 0) + n);
|
|
666
|
+
const parts = incomingParts.get(id) ?? { calls: 0, named: 0, attrs: 0 };
|
|
667
|
+
parts[kind] += n;
|
|
668
|
+
incomingParts.set(id, parts);
|
|
669
|
+
};
|
|
484
670
|
for (const e of graph.edges) {
|
|
485
671
|
if (e.relationship_type === "CALLS" && symbolIds.has(e.to_external_id)) {
|
|
486
|
-
|
|
672
|
+
addIncoming(e.to_external_id, "calls", 1);
|
|
487
673
|
}
|
|
488
|
-
else if (e.relationship_type === "IMPORTS"
|
|
489
|
-
|
|
674
|
+
else if (e.relationship_type === "IMPORTS") {
|
|
675
|
+
const named = e.properties?.symbol_imports;
|
|
676
|
+
if (Array.isArray(named))
|
|
677
|
+
for (const id of new Set(named)) {
|
|
678
|
+
if (typeof id === "string" && symbolIds.has(id) && fileBySymbolId.get(id) === e.to_external_id) {
|
|
679
|
+
addIncoming(id, "named", 1);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const refs = e.properties?.symbol_references;
|
|
683
|
+
if (refs && typeof refs === "object" && !Array.isArray(refs)) {
|
|
684
|
+
for (const [id, count] of Object.entries(refs)) {
|
|
685
|
+
if (symbolIds.has(id) && typeof count === "number" && Number.isSafeInteger(count) && count > 0 && fileBySymbolId.get(id) === e.to_external_id) {
|
|
686
|
+
addIncoming(id, "attrs", count);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
490
690
|
}
|
|
491
691
|
}
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
692
|
+
const incomingBreakdown = (id) => {
|
|
693
|
+
const parts = incomingParts.get(id);
|
|
694
|
+
if (!parts || (parts.named === 0 && parts.attrs === 0))
|
|
695
|
+
return "";
|
|
696
|
+
const bits = [`${parts.calls} call${parts.calls === 1 ? "" : "s"}`];
|
|
697
|
+
if (parts.named > 0)
|
|
698
|
+
bits.push(`${parts.named} named import${parts.named === 1 ? "" : "s"}`);
|
|
699
|
+
if (parts.attrs > 0)
|
|
700
|
+
bits.push(`${parts.attrs} module.attribute reference${parts.attrs === 1 ? "" : "s"}`);
|
|
701
|
+
return ` (${bits.join(" + ")})`;
|
|
702
|
+
};
|
|
500
703
|
// Per-symbol churn share: file churn weighted by the symbol's line span so one
|
|
501
704
|
// hot file no longer awards its full churn to every method it contains.
|
|
502
705
|
const fileComplexityTotals = new Map();
|
|
@@ -526,6 +729,7 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
526
729
|
// whose own name, or a retained external callee, is a destructive call.
|
|
527
730
|
const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
|
|
528
731
|
const lastSeg = (name) => name.split(".").pop() ?? name;
|
|
732
|
+
const isDestructiveTerminal = (name) => DESTRUCTIVE_CALL_RE.test(name) && !/^(?:deleted|purged|dropped|destroyed)[A-Za-z0-9_]*$/i.test(name);
|
|
529
733
|
// A sink is a destructive call on an EXTERNAL surface — a persistence store, an
|
|
530
734
|
// admin/service client, a database handle. In-repo methods named `delete` are
|
|
531
735
|
// not sinks by name alone (CHASM's `Node.delete` is a tree op, not a data loss).
|
|
@@ -539,25 +743,36 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
539
743
|
return undefined;
|
|
540
744
|
const ext = n.properties?.external_callees ?? [];
|
|
541
745
|
const importedStatic = n.properties?.imported_static_callees ?? [];
|
|
746
|
+
const constructorChains = n.properties?.constructor_chain_sinks;
|
|
747
|
+
if (Array.isArray(constructorChains)) {
|
|
748
|
+
const retained = constructorChains.find((entry) => {
|
|
749
|
+
if (!entry || typeof entry !== "object")
|
|
750
|
+
return false;
|
|
751
|
+
const fact = entry;
|
|
752
|
+
return typeof fact.callee === "string" && typeof fact.constructor_symbol === "string" && nodeById.has(fact.constructor_symbol);
|
|
753
|
+
});
|
|
754
|
+
if (retained)
|
|
755
|
+
return retained.callee;
|
|
756
|
+
}
|
|
542
757
|
// A receiver FIELD path is `x.field.Method` — no call parentheses before the last
|
|
543
758
|
// segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
|
|
544
759
|
const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
|
|
545
760
|
const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
|
|
546
|
-
const
|
|
547
|
-
|
|
761
|
+
const persistentRemove = (c) => REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c)) && !BROWSER_WEB_STORAGE_FIELD_RE.test(fieldOf(c));
|
|
762
|
+
const destructive = ext.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
|
|
763
|
+
persistentRemove(c) ||
|
|
548
764
|
extraSinks.some((re) => re.test(lastSeg(c)))));
|
|
549
765
|
if (destructive)
|
|
550
766
|
return destructive;
|
|
551
|
-
return importedStatic.find((c) => viaField(c) && (
|
|
767
|
+
return importedStatic.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
|
|
552
768
|
IMPORTED_REPLACEMENT_CALL_RE.test(lastSeg(c)) ||
|
|
553
|
-
(
|
|
769
|
+
persistentRemove(c) ||
|
|
554
770
|
extraSinks.some((re) => re.test(lastSeg(c)))));
|
|
555
771
|
};
|
|
556
|
-
const isSink = (id) => sinkCallee(id) !== undefined;
|
|
557
772
|
const reachedSinkFrom = (id, depth, seen) => {
|
|
558
773
|
const own = sinkCallee(id);
|
|
559
774
|
if (own)
|
|
560
|
-
return own;
|
|
775
|
+
return { callee: own, owner: id };
|
|
561
776
|
if (depth === 0)
|
|
562
777
|
return undefined;
|
|
563
778
|
for (const t of fanOutTargets.get(id) ?? []) {
|
|
@@ -570,44 +785,44 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
570
785
|
}
|
|
571
786
|
return undefined;
|
|
572
787
|
};
|
|
573
|
-
const
|
|
788
|
+
const endpointHandlers = new Set(graph.edges
|
|
789
|
+
.filter((e) => e.relationship_type === "IMPLEMENTED_IN" && nodeById.get(e.from_external_id)?.kind === "Endpoint")
|
|
790
|
+
.map((e) => e.to_external_id));
|
|
791
|
+
const frameworkInvoked = (node) => node.properties.cli_entrypoint === true || endpointHandlers.has(node.external_id);
|
|
792
|
+
const sinkHits = new Map(normalizationSymbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
|
|
793
|
+
const sinkReached = new Map([...sinkHits].map(([k, v]) => [k, v?.callee]));
|
|
574
794
|
const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
|
|
575
795
|
const depthCtx = buildFlowDepthContext(graph);
|
|
576
796
|
const staticLinked = staticTestLinkedIds(graph, symbolIds);
|
|
577
797
|
const candidateLinked = candidateSignalIds(graph, symbolIds);
|
|
578
798
|
const detectionFor = (id) => staticLinked.has(id) ? "associated" : candidateLinked.has(id) ? "candidate" : "none";
|
|
799
|
+
// R7 ranking-only hygiene happens AFTER the stable ORS normalization population is
|
|
800
|
+
// chosen. These declaration/delegate exclusions therefore remove only worklist
|
|
801
|
+
// slots: they never change graph facts, denominator membership, evidence, or a
|
|
802
|
+
// peer's P/I normalization.
|
|
803
|
+
const postNormalizationExcluded = (node) => {
|
|
804
|
+
if (node.properties.ranking_exclusion_code === "python_class_with_methods" || node.properties.ranking_exclusion_code === "python_structural_container" || node.properties.ranking_exclusion_reason === "python_structural_container")
|
|
805
|
+
return true;
|
|
806
|
+
const code = node.properties.ranking_exclusion_reason_code;
|
|
807
|
+
if (node.properties.ranking_exclusion_code === "trivial_constructor" ||
|
|
808
|
+
node.properties.ranking_exclusion_code === "enum_declaration" ||
|
|
809
|
+
node.properties.ranking_exclusion_code === "data_shape_declaration" ||
|
|
810
|
+
code === "python_trivial_thread_local_initializer" ||
|
|
811
|
+
code === "python_trivial_constructor" ||
|
|
812
|
+
code === "python_stdlib_enum_declaration" ||
|
|
813
|
+
code === "python_data_shape_declaration")
|
|
814
|
+
return true;
|
|
815
|
+
return (node.properties.ranking_exclusion_code === "thin_external_delegate" || code === "python_thin_external_delegate")
|
|
816
|
+
&& deriveDataSensitivity(node) <= 1
|
|
817
|
+
&& sinkCallee(node.external_id) === undefined;
|
|
818
|
+
};
|
|
819
|
+
const worklistSymbols = symbols.filter((node) => !postNormalizationExcluded(node));
|
|
579
820
|
if (opts.legacy) {
|
|
580
|
-
//
|
|
581
|
-
const legacyIds = new Set(symbols.map((s) => s.external_id));
|
|
582
|
-
const legacyByFile = new Map();
|
|
583
|
-
for (const s of symbols) {
|
|
584
|
-
const file = symbolFile(s);
|
|
585
|
-
const list = legacyByFile.get(file);
|
|
586
|
-
if (list)
|
|
587
|
-
list.push(s);
|
|
588
|
-
else
|
|
589
|
-
legacyByFile.set(file, [s]);
|
|
590
|
-
}
|
|
591
|
-
const legacyIncoming = new Map();
|
|
592
|
-
const legacyFileImports = new Map();
|
|
593
|
-
for (const e of graph.edges) {
|
|
594
|
-
if (e.relationship_type === "CALLS" && legacyIds.has(e.to_external_id)) {
|
|
595
|
-
legacyIncoming.set(e.to_external_id, (legacyIncoming.get(e.to_external_id) ?? 0) + 1);
|
|
596
|
-
}
|
|
597
|
-
else if (e.relationship_type === "IMPORTS" && legacyByFile.has(e.to_external_id)) {
|
|
598
|
-
legacyFileImports.set(e.to_external_id, (legacyFileImports.get(e.to_external_id) ?? 0) + 1);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
for (const [file, count] of legacyFileImports) {
|
|
602
|
-
const syms = legacyByFile.get(file) ?? [];
|
|
603
|
-
const share = syms.length > 0 ? count / syms.length : 0;
|
|
604
|
-
for (const s of syms)
|
|
605
|
-
legacyIncoming.set(s.external_id, (legacyIncoming.get(s.external_id) ?? 0) + share);
|
|
606
|
-
}
|
|
821
|
+
// The formula remains available; neither lane may invent sibling references.
|
|
607
822
|
return worklistSymbols
|
|
608
823
|
.map((s) => {
|
|
609
824
|
const file = symbolFile(s);
|
|
610
|
-
const incoming_refs =
|
|
825
|
+
const incoming_refs = incoming.get(s.external_id) ?? 0;
|
|
611
826
|
const git_churn = churn.get(file) ?? 0;
|
|
612
827
|
const isEntry = entryPoint.get(s.external_id) ?? false;
|
|
613
828
|
const churnForScore = Math.min(git_churn, 500);
|
|
@@ -615,12 +830,14 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
615
830
|
const reasons = [
|
|
616
831
|
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}`,
|
|
617
832
|
churnAvailable
|
|
618
|
-
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in
|
|
619
|
-
:
|
|
833
|
+
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in recent history${git_churn > 500 ? " (score capped at 500)" : ""}`
|
|
834
|
+
: churnResult.state === "partial"
|
|
835
|
+
? `${git_churn} partial git churn line${git_churn === 1 ? "" : "s"} retained from ${churnResult.commitsScanned} commit${churnResult.commitsScanned === 1 ? "" : "s"} — ${churnResult.reason ?? "bounded acquisition incomplete"}`
|
|
836
|
+
: "Git churn unavailable — provisional static-only ranking"
|
|
620
837
|
];
|
|
621
838
|
if (isEntry)
|
|
622
839
|
reasons.push("near an API/route/handler entry point");
|
|
623
|
-
return { id: s.external_id, title: s.title || s.external_id, file, risk_score: score, incoming_refs, git_churn, churn_available: churnAvailable, entry_point: isEntry, reasons };
|
|
840
|
+
return { id: s.external_id, title: s.title || s.external_id, file, risk_score: score, incoming_refs, git_churn, churn_available: churnAvailable, churn_state: churnResult.state, ...(churnResult.reason ? { churn_reason: churnResult.reason } : {}), commits_scanned: churnResult.commitsScanned, entry_point: isEntry, reasons };
|
|
624
841
|
})
|
|
625
842
|
.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))
|
|
626
843
|
.slice(0, limit);
|
|
@@ -675,15 +892,21 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
675
892
|
const d = raw.d;
|
|
676
893
|
const detectionTier = detectionFor(s.external_id);
|
|
677
894
|
let score = Math.round(pExact * iExact * d * 10) / 10;
|
|
678
|
-
|
|
895
|
+
// A framework-invoked entry (a registered CLI command, or a handler bound to an
|
|
896
|
+
// Endpoint) has no static caller BY CONSTRUCTION; "disconnected" means dead-code
|
|
897
|
+
// shaped, which an entry the framework calls is not. Before per-symbol fan-in,
|
|
898
|
+
// such entries escaped this dampener only through file-split import counts.
|
|
899
|
+
const disconnected = incoming_refs === 0 && fan_out === 0 && !frameworkInvoked(s);
|
|
679
900
|
if (disconnected)
|
|
680
901
|
score = Math.round(score * 0.25 * 10) / 10;
|
|
681
902
|
const reasons = [
|
|
682
903
|
`ORS ${score} ≈ P${p} × I${i} × D${d}`,
|
|
683
|
-
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}
|
|
904
|
+
`${incoming_refs} incoming symbol-specific structural reference${incoming_refs === 1 ? "" : "s"}${incomingBreakdown(s.external_id)}`,
|
|
684
905
|
churnAvailable
|
|
685
|
-
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in
|
|
686
|
-
:
|
|
906
|
+
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in recent history`
|
|
907
|
+
: churnResult.state === "partial"
|
|
908
|
+
? `${git_churn} partial git churn line${git_churn === 1 ? "" : "s"} retained from ${churnResult.commitsScanned} commit${churnResult.commitsScanned === 1 ? "" : "s"} — ${churnResult.reason ?? "bounded acquisition incomplete"}`
|
|
909
|
+
: "Git churn unavailable — provisional static-only ranking",
|
|
687
910
|
`route weight ${route_weight}, data sensitivity ${data_sensitivity}, flow position ${flow_position}, complexity ${complexity_proxy}, fan-out ${fan_out}`
|
|
688
911
|
];
|
|
689
912
|
if (isEntry)
|
|
@@ -705,7 +928,7 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
705
928
|
reasons.push(`config override (${override.action}): ${override.reason}`);
|
|
706
929
|
return {
|
|
707
930
|
...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
|
|
708
|
-
...(
|
|
931
|
+
...(sinkHits.get(s.external_id) ? { sink_callee: sinkHits.get(s.external_id).callee, sink_owner: sinkHits.get(s.external_id).owner } : {}),
|
|
709
932
|
...(raw.scheduledEntry ? { scheduled_entry: true } : {}),
|
|
710
933
|
detection_tier: detectionTier,
|
|
711
934
|
id: s.external_id,
|
|
@@ -715,6 +938,9 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
715
938
|
incoming_refs,
|
|
716
939
|
git_churn,
|
|
717
940
|
churn_available: churnAvailable,
|
|
941
|
+
churn_state: churnResult.state,
|
|
942
|
+
...(churnResult.reason ? { churn_reason: churnResult.reason } : {}),
|
|
943
|
+
commits_scanned: churnResult.commitsScanned,
|
|
718
944
|
entry_point: isEntry,
|
|
719
945
|
reasons,
|
|
720
946
|
probability: p,
|
|
@@ -822,6 +1048,8 @@ export function configDisclosureFor(graph, repoRoot) {
|
|
|
822
1048
|
rankExcludePaths: cfg.classification.rank_exclude_paths,
|
|
823
1049
|
floor: cfg.tuning.irreversibility_floor,
|
|
824
1050
|
silence: cfg.tuning.silence_multiplier,
|
|
1051
|
+
tuning: { churn_window_days: cfg.tuning.churn_window_days, churn_max_commits: cfg.tuning.churn_max_commits, churn_timeout_seconds: cfg.tuning.churn_timeout_seconds },
|
|
1052
|
+
proof: cfg.proof,
|
|
825
1053
|
classification: {
|
|
826
1054
|
test_support_paths: cfg.classification.test_support_paths,
|
|
827
1055
|
scheduled_entry_paths: cfg.classification.scheduled_entry_paths,
|