@orangepro/orangepro-mcp 0.2.44 → 0.2.45
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 +318 -30
- package/dist/local/analyze/treeSitter/engine.js +1058 -90
- package/dist/local/autoProve.js +208 -102
- package/dist/local/operations.js +65 -16
- package/dist/local/proofDoctor.js +20 -3
- package/dist/local/provenance.js +2 -2
- package/dist/local/score/risk.js +284 -104
- package/dist/local/score/riskConfig.js +17 -1
- package/dist/local/viz/behaviorReportData.js +54 -18
- package/dist/local/viz/behaviorReportHtml.js +12 -1
- 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,147 @@ function confirmedBehaviorIds(graph, provenIds) {
|
|
|
74
78
|
}
|
|
75
79
|
return ids;
|
|
76
80
|
}
|
|
77
|
-
|
|
81
|
+
/** Bounded repository-wide history acquisition. Deliberately constants, not hidden timeouts. */
|
|
82
|
+
export const GIT_CHURN_MAX_COMMITS = 5_000;
|
|
83
|
+
export const GIT_CHURN_TIMEOUT_MS = 60_000;
|
|
84
|
+
const GIT_FIRST_COMMIT_BATCH = 200;
|
|
85
|
+
/**
|
|
86
|
+
* This worker intentionally uses spawn, not execFileSync: stdout is parsed as it
|
|
87
|
+
* arrives, so a 60s bound returns the values already observed instead of erasing
|
|
88
|
+
* completed chunks. It retains only requested paths, bounding the worker map and
|
|
89
|
+
* handoff payload by ranking input rather than repository history. The scoring API
|
|
90
|
+
* is synchronous, therefore the worker signals only after writing its result file.
|
|
91
|
+
*/
|
|
92
|
+
const GIT_CHURN_WORKER_SOURCE = String.raw `
|
|
93
|
+
const { spawn } = require("node:child_process");
|
|
94
|
+
const { writeFileSync } = require("node:fs");
|
|
95
|
+
const { workerData } = require("node:worker_threads");
|
|
96
|
+
const state = new Int32Array(workerData.signal);
|
|
97
|
+
const values = new Map();
|
|
98
|
+
const trackedFiles = new Set(workerData.files);
|
|
99
|
+
let commitsScanned = 0;
|
|
100
|
+
let commitLimitReached = false;
|
|
101
|
+
let expectingTimestamp = false;
|
|
102
|
+
let renameRecord = null;
|
|
103
|
+
let renameOldPath = null;
|
|
104
|
+
let remainder = "";
|
|
105
|
+
let stderr = "";
|
|
106
|
+
let done = false;
|
|
107
|
+
let timedOut = false;
|
|
108
|
+
let timer;
|
|
109
|
+
let child;
|
|
110
|
+
let childClosed = false;
|
|
111
|
+
const firstLine = (text) => String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
|
|
112
|
+
const add = (path, adds, dels) => {
|
|
113
|
+
if (!path || commitLimitReached || !trackedFiles.has(path)) return;
|
|
114
|
+
values.set(path, (values.get(path) || 0) + adds + dels);
|
|
115
|
+
};
|
|
116
|
+
const consume = (token) => {
|
|
117
|
+
if (renameRecord) {
|
|
118
|
+
// Git numstat -z represents a rename as an empty-path record followed by
|
|
119
|
+
// the old path and then the new path. Attribute churn to the current path.
|
|
120
|
+
if (renameOldPath === null) {
|
|
121
|
+
renameOldPath = token;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
add(token, renameRecord.adds, renameRecord.dels);
|
|
125
|
+
renameRecord = null;
|
|
126
|
+
renameOldPath = null;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (/^[0-9a-f]{40}$/i.test(token)) { expectingTimestamp = true; return; }
|
|
130
|
+
if (expectingTimestamp && /^\d+$/.test(token)) {
|
|
131
|
+
expectingTimestamp = false;
|
|
132
|
+
if (commitsScanned >= workerData.maxCommits) {
|
|
133
|
+
commitLimitReached = true;
|
|
134
|
+
if (child) child.kill("SIGTERM");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
commitsScanned++;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const match = token.replace(/^\n/, "").match(/^(-|\d+)\t(-|\d+)\t([\s\S]*)$/);
|
|
141
|
+
if (!match) return;
|
|
142
|
+
const adds = match[1] === "-" ? 0 : Number(match[1]);
|
|
143
|
+
const dels = match[2] === "-" ? 0 : Number(match[2]);
|
|
144
|
+
if (match[3] === "") { renameRecord = { adds, dels }; renameOldPath = null; return; }
|
|
145
|
+
add(match[3], adds, dels);
|
|
146
|
+
};
|
|
147
|
+
const finish = (stateName, reason) => {
|
|
148
|
+
if (done) return;
|
|
149
|
+
done = true;
|
|
150
|
+
if (timer) clearTimeout(timer);
|
|
151
|
+
try {
|
|
152
|
+
writeFileSync(workerData.resultPath, JSON.stringify({ state: stateName, reason, commitsScanned, values: [...values].sort(([a], [b]) => a.localeCompare(b)) }));
|
|
153
|
+
} finally {
|
|
154
|
+
Atomics.store(state, 0, 1);
|
|
155
|
+
Atomics.notify(state, 0);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const stopChild = (signal) => {
|
|
159
|
+
if (!child || childClosed) return;
|
|
160
|
+
try { child.kill(signal); } catch {}
|
|
161
|
+
};
|
|
162
|
+
// The parent terminates this worker after consuming the bounded handoff. Make
|
|
163
|
+
// sure an unresponsive git child cannot survive that termination.
|
|
164
|
+
process.on("exit", () => stopChild("SIGKILL"));
|
|
165
|
+
try {
|
|
166
|
+
child = spawn("git", ["log", "--no-merges", "--numstat", "--format=%H%x00%ct", "-z", "--since=" + workerData.window, "--max-count=" + (workerData.maxCommits + 1)], {
|
|
167
|
+
cwd: workerData.root,
|
|
168
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
finish("unavailable", error && error.code === "ENOENT" ? "git_missing" : "error:" + firstLine(error && error.message));
|
|
172
|
+
}
|
|
173
|
+
timer = setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
stopChild("SIGTERM");
|
|
176
|
+
setTimeout(() => finish("partial", "timeout"), 100).unref();
|
|
177
|
+
}, workerData.timeoutMs);
|
|
178
|
+
if (child) {
|
|
179
|
+
child.stdout.on("data", (chunk) => {
|
|
180
|
+
remainder += chunk.toString("utf8");
|
|
181
|
+
let nul;
|
|
182
|
+
while ((nul = remainder.indexOf("\0")) >= 0) {
|
|
183
|
+
consume(remainder.slice(0, nul));
|
|
184
|
+
remainder = remainder.slice(nul + 1);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
child.stderr.on("data", (chunk) => { if (stderr.length < 4096) stderr += chunk.toString("utf8"); });
|
|
188
|
+
child.on("error", (error) => finish("unavailable", error && error.code === "ENOENT" ? "git_missing" : "error:" + firstLine(error && error.message)));
|
|
189
|
+
child.on("close", (code) => {
|
|
190
|
+
childClosed = true;
|
|
191
|
+
if (remainder) consume(remainder);
|
|
192
|
+
if (timedOut) return finish("partial", "timeout");
|
|
193
|
+
if (commitLimitReached) return finish("partial", "commit_limit");
|
|
194
|
+
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);
|
|
195
|
+
finish(commitsScanned > 0 ? "partial" : "unavailable", "error:" + (firstLine(stderr) || "git log exited " + code));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
`;
|
|
199
|
+
const historyCache = new Map();
|
|
200
|
+
function firstErrorLine(error) {
|
|
201
|
+
const e = error;
|
|
202
|
+
const raw = e.stderr ? String(e.stderr) : e.message;
|
|
203
|
+
return raw.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "unknown Git error";
|
|
204
|
+
}
|
|
205
|
+
function unavailableHealth(root, churnWindow, error) {
|
|
206
|
+
const e = error;
|
|
207
|
+
const line = firstErrorLine(error);
|
|
208
|
+
const reason = e.code === "ENOENT"
|
|
209
|
+
? "git_missing"
|
|
210
|
+
: /not a git repository/i.test(line)
|
|
211
|
+
? "not_a_git_repo"
|
|
212
|
+
: `error:${line}`;
|
|
213
|
+
return { sourceRoot: root ?? null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow, churnState: "unavailable", churnAvailable: false, reason };
|
|
214
|
+
}
|
|
78
215
|
export function inspectRiskInputHealth(root, churnWindow) {
|
|
79
|
-
const
|
|
216
|
+
const configuredDays = loadRiskConfig(root ?? "").config.tuning.churn_window_days;
|
|
217
|
+
const unavailableWindow = churnWindow ?? `${configuredDays} days before HEAD`;
|
|
80
218
|
if (!root)
|
|
81
|
-
return { sourceRoot: null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnAvailable: false, reason: "
|
|
219
|
+
return { sourceRoot: null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnState: "unavailable", churnAvailable: false, reason: "not_a_git_repo" };
|
|
82
220
|
try {
|
|
83
|
-
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "
|
|
221
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 4000 }).trim();
|
|
84
222
|
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
85
223
|
const commitDate = execFileSync("git", ["show", "-s", "--format=%cI", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
86
224
|
const shallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() === "true";
|
|
@@ -94,7 +232,7 @@ export function inspectRiskInputHealth(root, churnWindow) {
|
|
|
94
232
|
// A normal full clone has no promisor-remote configuration.
|
|
95
233
|
}
|
|
96
234
|
const commitMs = Date.parse(commitDate);
|
|
97
|
-
const resolvedWindow = churnWindow ?? (Number.isFinite(commitMs) ? new Date(commitMs -
|
|
235
|
+
const resolvedWindow = churnWindow ?? (Number.isFinite(commitMs) ? new Date(commitMs - configuredDays * 24 * 60 * 60 * 1000).toISOString() : unavailableWindow);
|
|
98
236
|
return {
|
|
99
237
|
sourceRoot: root,
|
|
100
238
|
gitRoot,
|
|
@@ -102,59 +240,85 @@ export function inspectRiskInputHealth(root, churnWindow) {
|
|
|
102
240
|
commitDate,
|
|
103
241
|
history: shallow ? "shallow" : partial ? "partial" : "full",
|
|
104
242
|
churnWindow: resolvedWindow,
|
|
243
|
+
churnState: shallow || partial ? "partial" : "complete",
|
|
105
244
|
churnAvailable: !shallow && !partial,
|
|
106
245
|
reason: shallow
|
|
107
|
-
? "shallow
|
|
246
|
+
? "shallow clone: churn based on available history"
|
|
108
247
|
: partial
|
|
109
|
-
? "partial
|
|
248
|
+
? "partial clone: churn based on available history"
|
|
110
249
|
: undefined
|
|
111
250
|
};
|
|
112
251
|
}
|
|
113
|
-
catch {
|
|
114
|
-
return
|
|
252
|
+
catch (error) {
|
|
253
|
+
return unavailableHealth(root, unavailableWindow, error);
|
|
115
254
|
}
|
|
116
255
|
}
|
|
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
|
-
|
|
256
|
+
function gitChurn(root, files, window, health = inspectRiskInputHealth(root, window)) {
|
|
257
|
+
const empty = { values: new Map(), state: "unavailable", commitsScanned: 0, reason: health.reason };
|
|
258
|
+
if (!root || health.churnState === "unavailable")
|
|
259
|
+
return empty;
|
|
260
|
+
// Values are retained only for the requested paths, so that set and clone
|
|
261
|
+
// state are part of cache identity. A checkout can be marked partial at an
|
|
262
|
+
// unchanged HEAD and must then remain truthfully provisional.
|
|
263
|
+
const requestedFiles = [...new Set(files)].sort();
|
|
264
|
+
const requested = new Set(requestedFiles);
|
|
265
|
+
const key = `${health.gitRoot ?? root}\0${health.commit ?? ""}\0${health.history}\0${window}\0${hashString(JSON.stringify(requestedFiles))}`;
|
|
266
|
+
const cached = historyCache.get(key);
|
|
267
|
+
if (cached)
|
|
268
|
+
return { ...cached, values: new Map(cached.values) };
|
|
269
|
+
const dir = mkdtempSync(join(tmpdir(), "orangepro-churn-"));
|
|
270
|
+
const resultPath = join(dir, "result.json");
|
|
271
|
+
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
|
272
|
+
const state = new Int32Array(signal);
|
|
273
|
+
let parsed;
|
|
274
|
+
try {
|
|
275
|
+
const worker = new Worker(GIT_CHURN_WORKER_SOURCE, {
|
|
276
|
+
eval: true,
|
|
277
|
+
workerData: {
|
|
278
|
+
root,
|
|
279
|
+
window,
|
|
280
|
+
files: requestedFiles,
|
|
281
|
+
maxCommits: GIT_CHURN_MAX_COMMITS,
|
|
282
|
+
timeoutMs: GIT_CHURN_TIMEOUT_MS,
|
|
283
|
+
cloneState: health.history,
|
|
284
|
+
resultPath,
|
|
285
|
+
signal
|
|
138
286
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
287
|
+
});
|
|
288
|
+
// The worker publishes a partial result 100ms after its child timeout; leave
|
|
289
|
+
// explicit scheduling room before the synchronous API forces it down.
|
|
290
|
+
Atomics.wait(state, 0, 0, GIT_CHURN_TIMEOUT_MS + 2_000);
|
|
291
|
+
if (Atomics.load(state, 0) === 1)
|
|
292
|
+
parsed = JSON.parse(readFileSync(resultPath, "utf8"));
|
|
293
|
+
void worker.terminate().catch(() => undefined);
|
|
143
294
|
}
|
|
144
|
-
|
|
295
|
+
catch (error) {
|
|
296
|
+
parsed = { state: "unavailable", reason: `error:${firstErrorLine(error)}`, commitsScanned: 0, values: [] };
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
rmSync(dir, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
const full = parsed
|
|
302
|
+
? { state: parsed.state, reason: parsed.reason, commitsScanned: parsed.commitsScanned, values: new Map(parsed.values) }
|
|
303
|
+
: { state: "partial", reason: "timeout", commitsScanned: 0, values: new Map() };
|
|
304
|
+
historyCache.set(key, full);
|
|
305
|
+
// Defense in depth: a malformed worker payload may never score an unrequested path.
|
|
306
|
+
return { ...full, values: new Map([...full.values].filter(([file]) => requested.has(file))) };
|
|
145
307
|
}
|
|
308
|
+
/** Preserve the released ORS `is_new_code` input. This is separate from the
|
|
309
|
+
* recent-churn window because a file's first add may predate that window. */
|
|
146
310
|
function gitFirstCommitBatch(root, files) {
|
|
147
311
|
const out = new Map();
|
|
148
312
|
if (!root || files.length === 0)
|
|
149
313
|
return out;
|
|
150
|
-
for (let i = 0; i < files.length; i +=
|
|
151
|
-
const batch = files.slice(i, i +
|
|
314
|
+
for (let i = 0; i < files.length; i += GIT_FIRST_COMMIT_BATCH) {
|
|
315
|
+
const batch = files.slice(i, i + GIT_FIRST_COMMIT_BATCH);
|
|
152
316
|
try {
|
|
153
317
|
const stdout = execFileSync("git", ["log", "--diff-filter=A", "--reverse", "--format=format:%ct", "--name-only", "--", ...batch], {
|
|
154
318
|
cwd: root,
|
|
155
319
|
encoding: "utf8",
|
|
156
320
|
stdio: ["ignore", "pipe", "ignore"],
|
|
157
|
-
timeout:
|
|
321
|
+
timeout: 4_000,
|
|
158
322
|
maxBuffer: 2_000_000
|
|
159
323
|
});
|
|
160
324
|
let currentTs = 0;
|
|
@@ -169,30 +333,33 @@ function gitFirstCommitBatch(root, files) {
|
|
|
169
333
|
currentTs = ts;
|
|
170
334
|
continue;
|
|
171
335
|
}
|
|
172
|
-
if (currentTs > 0 && !out.has(trimmed))
|
|
336
|
+
if (currentTs > 0 && !out.has(trimmed))
|
|
173
337
|
out.set(trimmed, currentTs);
|
|
174
|
-
}
|
|
175
338
|
}
|
|
176
339
|
}
|
|
177
340
|
catch {
|
|
178
|
-
|
|
341
|
+
// Preserve the released fail-closed behavior for this optional ORS signal.
|
|
179
342
|
}
|
|
180
343
|
}
|
|
181
344
|
return out;
|
|
182
345
|
}
|
|
346
|
+
/** Repository-level acquisition metadata for report/provenance callers. */
|
|
347
|
+
export function inspectRiskChurn(root, files, churnWindow) {
|
|
348
|
+
const health = inspectRiskInputHealth(root, churnWindow);
|
|
349
|
+
const result = gitChurn(root, files, health.churnWindow, health);
|
|
350
|
+
return { state: result.state, reason: result.reason, commitsScanned: result.commitsScanned };
|
|
351
|
+
}
|
|
183
352
|
/** Exact deterministic identity of the Git-derived inputs consumed by ORS. */
|
|
184
353
|
export function riskHistoryFingerprint(root, files, churnWindow) {
|
|
185
354
|
const health = inspectRiskInputHealth(root, churnWindow);
|
|
186
355
|
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();
|
|
356
|
+
const churn = gitChurn(root, sortedFiles, health.churnWindow, health);
|
|
357
|
+
const first = churn.state === "complete" ? gitFirstCommitBatch(root, sortedFiles) : new Map();
|
|
192
358
|
return hashString(JSON.stringify({
|
|
193
|
-
state:
|
|
359
|
+
state: churn.state,
|
|
194
360
|
window: health.churnWindow,
|
|
195
|
-
|
|
361
|
+
reason: churn.reason,
|
|
362
|
+
commits_scanned: churn.commitsScanned,
|
|
196
363
|
churn: sortedFiles.map((file) => [file, churn.values.get(file) ?? 0]),
|
|
197
364
|
first_commit: sortedFiles.map((file) => [file, first.get(file) ?? 0])
|
|
198
365
|
}));
|
|
@@ -446,8 +613,8 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
446
613
|
// worklist but do not rescale an unchanged peer.
|
|
447
614
|
const normalizationSymbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && rankEligible(n) && !isSuppressed(n.external_id));
|
|
448
615
|
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
616
|
const symbolIds = new Set(normalizationSymbols.map((s) => s.external_id));
|
|
617
|
+
const fileBySymbolId = new Map(normalizationSymbols.map((s) => [s.external_id, symbolFile(s)]));
|
|
451
618
|
const symbolsByFile = new Map();
|
|
452
619
|
for (const s of normalizationSymbols) {
|
|
453
620
|
const file = symbolFile(s);
|
|
@@ -467,36 +634,40 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
467
634
|
const sensitivityIgnore = cfg.classification.sensitivity_ignore.map(globToRegExp);
|
|
468
635
|
const inputHealth = inspectRiskInputHealth(repoRoot, opts.churnWindow);
|
|
469
636
|
const churnWindow = inputHealth.churnWindow;
|
|
470
|
-
const churnResult =
|
|
637
|
+
const churnResult = gitChurn(repoRoot, files, churnWindow, inputHealth);
|
|
471
638
|
const churn = churnResult.values;
|
|
472
|
-
const churnAvailable =
|
|
639
|
+
const churnAvailable = churnResult.state === "complete";
|
|
473
640
|
const firstCommitTs = churnAvailable ? gitFirstCommitBatch(repoRoot, files) : new Map();
|
|
474
641
|
const commitMs = Date.parse(inputHealth.commitDate ?? "");
|
|
475
642
|
const graphMs = Date.parse(graph.updated_at || graph.created_at || "");
|
|
476
643
|
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.
|
|
644
|
+
// A file-level IMPORTS edge does not reference any particular symbol. Only its
|
|
645
|
+
// AST-resolved named imports and non-call module.attribute references may be
|
|
646
|
+
// attributed; CALLS already has an exact symbol target. Older graphs without
|
|
647
|
+
// these metadata facts fail closed instead of redistributing imports.
|
|
482
648
|
const incoming = new Map();
|
|
483
|
-
const fileImports = new Map();
|
|
484
649
|
for (const e of graph.edges) {
|
|
485
650
|
if (e.relationship_type === "CALLS" && symbolIds.has(e.to_external_id)) {
|
|
486
651
|
incoming.set(e.to_external_id, (incoming.get(e.to_external_id) ?? 0) + 1);
|
|
487
652
|
}
|
|
488
|
-
else if (e.relationship_type === "IMPORTS"
|
|
489
|
-
|
|
653
|
+
else if (e.relationship_type === "IMPORTS") {
|
|
654
|
+
const named = e.properties?.symbol_imports;
|
|
655
|
+
if (Array.isArray(named))
|
|
656
|
+
for (const id of new Set(named)) {
|
|
657
|
+
if (typeof id === "string" && symbolIds.has(id) && fileBySymbolId.get(id) === e.to_external_id) {
|
|
658
|
+
incoming.set(id, (incoming.get(id) ?? 0) + 1);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const refs = e.properties?.symbol_references;
|
|
662
|
+
if (refs && typeof refs === "object" && !Array.isArray(refs)) {
|
|
663
|
+
for (const [id, count] of Object.entries(refs)) {
|
|
664
|
+
if (symbolIds.has(id) && typeof count === "number" && Number.isSafeInteger(count) && count > 0 && fileBySymbolId.get(id) === e.to_external_id) {
|
|
665
|
+
incoming.set(id, (incoming.get(id) ?? 0) + count);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
490
669
|
}
|
|
491
670
|
}
|
|
492
|
-
for (const [file, count] of fileImports) {
|
|
493
|
-
const syms = symbolsByFile.get(file) ?? [];
|
|
494
|
-
if (syms.length === 0)
|
|
495
|
-
continue;
|
|
496
|
-
const share = count / syms.length;
|
|
497
|
-
for (const s of syms)
|
|
498
|
-
incoming.set(s.external_id, (incoming.get(s.external_id) ?? 0) + share);
|
|
499
|
-
}
|
|
500
671
|
// Per-symbol churn share: file churn weighted by the symbol's line span so one
|
|
501
672
|
// hot file no longer awards its full churn to every method it contains.
|
|
502
673
|
const fileComplexityTotals = new Map();
|
|
@@ -526,6 +697,7 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
526
697
|
// whose own name, or a retained external callee, is a destructive call.
|
|
527
698
|
const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
|
|
528
699
|
const lastSeg = (name) => name.split(".").pop() ?? name;
|
|
700
|
+
const isDestructiveTerminal = (name) => DESTRUCTIVE_CALL_RE.test(name) && !/^(?:deleted|purged|dropped|destroyed)[A-Za-z0-9_]*$/i.test(name);
|
|
529
701
|
// A sink is a destructive call on an EXTERNAL surface — a persistence store, an
|
|
530
702
|
// admin/service client, a database handle. In-repo methods named `delete` are
|
|
531
703
|
// not sinks by name alone (CHASM's `Node.delete` is a tree op, not a data loss).
|
|
@@ -539,21 +711,31 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
539
711
|
return undefined;
|
|
540
712
|
const ext = n.properties?.external_callees ?? [];
|
|
541
713
|
const importedStatic = n.properties?.imported_static_callees ?? [];
|
|
714
|
+
const constructorChains = n.properties?.constructor_chain_sinks;
|
|
715
|
+
if (Array.isArray(constructorChains)) {
|
|
716
|
+
const retained = constructorChains.find((entry) => {
|
|
717
|
+
if (!entry || typeof entry !== "object")
|
|
718
|
+
return false;
|
|
719
|
+
const fact = entry;
|
|
720
|
+
return typeof fact.callee === "string" && typeof fact.constructor_symbol === "string" && nodeById.has(fact.constructor_symbol);
|
|
721
|
+
});
|
|
722
|
+
if (retained)
|
|
723
|
+
return retained.callee;
|
|
724
|
+
}
|
|
542
725
|
// A receiver FIELD path is `x.field.Method` — no call parentheses before the last
|
|
543
726
|
// segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
|
|
544
727
|
const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
|
|
545
728
|
const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
|
|
546
|
-
const destructive = ext.find((c) => viaField(c) && (
|
|
729
|
+
const destructive = ext.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
|
|
547
730
|
(REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
|
|
548
731
|
extraSinks.some((re) => re.test(lastSeg(c)))));
|
|
549
732
|
if (destructive)
|
|
550
733
|
return destructive;
|
|
551
|
-
return importedStatic.find((c) => viaField(c) && (
|
|
734
|
+
return importedStatic.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
|
|
552
735
|
IMPORTED_REPLACEMENT_CALL_RE.test(lastSeg(c)) ||
|
|
553
736
|
(REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
|
|
554
737
|
extraSinks.some((re) => re.test(lastSeg(c)))));
|
|
555
738
|
};
|
|
556
|
-
const isSink = (id) => sinkCallee(id) !== undefined;
|
|
557
739
|
const reachedSinkFrom = (id, depth, seen) => {
|
|
558
740
|
const own = sinkCallee(id);
|
|
559
741
|
if (own)
|
|
@@ -576,38 +758,27 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
576
758
|
const staticLinked = staticTestLinkedIds(graph, symbolIds);
|
|
577
759
|
const candidateLinked = candidateSignalIds(graph, symbolIds);
|
|
578
760
|
const detectionFor = (id) => staticLinked.has(id) ? "associated" : candidateLinked.has(id) ? "candidate" : "none";
|
|
761
|
+
// R7 ranking-only hygiene happens AFTER the stable ORS normalization population is
|
|
762
|
+
// chosen. These declaration/delegate exclusions therefore remove only worklist
|
|
763
|
+
// slots: they never change graph facts, denominator membership, evidence, or a
|
|
764
|
+
// peer's P/I normalization.
|
|
765
|
+
const postNormalizationExcluded = (node) => {
|
|
766
|
+
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")
|
|
767
|
+
return true;
|
|
768
|
+
const code = node.properties.ranking_exclusion_reason_code;
|
|
769
|
+
if (node.properties.ranking_exclusion_code === "trivial_constructor" || node.properties.ranking_exclusion_code === "enum_declaration" || code === "python_trivial_thread_local_initializer" || code === "python_stdlib_enum_declaration")
|
|
770
|
+
return true;
|
|
771
|
+
return (node.properties.ranking_exclusion_code === "thin_external_delegate" || code === "python_thin_external_delegate")
|
|
772
|
+
&& deriveDataSensitivity(node) <= 1
|
|
773
|
+
&& sinkCallee(node.external_id) === undefined;
|
|
774
|
+
};
|
|
775
|
+
const worklistSymbols = symbols.filter((node) => !postNormalizationExcluded(node));
|
|
579
776
|
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
|
-
}
|
|
777
|
+
// The formula remains available; neither lane may invent sibling references.
|
|
607
778
|
return worklistSymbols
|
|
608
779
|
.map((s) => {
|
|
609
780
|
const file = symbolFile(s);
|
|
610
|
-
const incoming_refs =
|
|
781
|
+
const incoming_refs = incoming.get(s.external_id) ?? 0;
|
|
611
782
|
const git_churn = churn.get(file) ?? 0;
|
|
612
783
|
const isEntry = entryPoint.get(s.external_id) ?? false;
|
|
613
784
|
const churnForScore = Math.min(git_churn, 500);
|
|
@@ -615,12 +786,14 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
615
786
|
const reasons = [
|
|
616
787
|
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}`,
|
|
617
788
|
churnAvailable
|
|
618
|
-
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in
|
|
619
|
-
:
|
|
789
|
+
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in recent history${git_churn > 500 ? " (score capped at 500)" : ""}`
|
|
790
|
+
: churnResult.state === "partial"
|
|
791
|
+
? `${git_churn} partial git churn line${git_churn === 1 ? "" : "s"} retained from ${churnResult.commitsScanned} commit${churnResult.commitsScanned === 1 ? "" : "s"} — ${churnResult.reason ?? "bounded acquisition incomplete"}`
|
|
792
|
+
: "Git churn unavailable — provisional static-only ranking"
|
|
620
793
|
];
|
|
621
794
|
if (isEntry)
|
|
622
795
|
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 };
|
|
796
|
+
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
797
|
})
|
|
625
798
|
.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
799
|
.slice(0, limit);
|
|
@@ -680,10 +853,12 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
680
853
|
score = Math.round(score * 0.25 * 10) / 10;
|
|
681
854
|
const reasons = [
|
|
682
855
|
`ORS ${score} ≈ P${p} × I${i} × D${d}`,
|
|
683
|
-
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}
|
|
856
|
+
`${incoming_refs} incoming symbol-specific structural reference${incoming_refs === 1 ? "" : "s"}`,
|
|
684
857
|
churnAvailable
|
|
685
|
-
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in
|
|
686
|
-
:
|
|
858
|
+
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in recent history`
|
|
859
|
+
: churnResult.state === "partial"
|
|
860
|
+
? `${git_churn} partial git churn line${git_churn === 1 ? "" : "s"} retained from ${churnResult.commitsScanned} commit${churnResult.commitsScanned === 1 ? "" : "s"} — ${churnResult.reason ?? "bounded acquisition incomplete"}`
|
|
861
|
+
: "Git churn unavailable — provisional static-only ranking",
|
|
687
862
|
`route weight ${route_weight}, data sensitivity ${data_sensitivity}, flow position ${flow_position}, complexity ${complexity_proxy}, fan-out ${fan_out}`
|
|
688
863
|
];
|
|
689
864
|
if (isEntry)
|
|
@@ -715,6 +890,9 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
715
890
|
incoming_refs,
|
|
716
891
|
git_churn,
|
|
717
892
|
churn_available: churnAvailable,
|
|
893
|
+
churn_state: churnResult.state,
|
|
894
|
+
...(churnResult.reason ? { churn_reason: churnResult.reason } : {}),
|
|
895
|
+
commits_scanned: churnResult.commitsScanned,
|
|
718
896
|
entry_point: isEntry,
|
|
719
897
|
reasons,
|
|
720
898
|
probability: p,
|
|
@@ -822,6 +1000,8 @@ export function configDisclosureFor(graph, repoRoot) {
|
|
|
822
1000
|
rankExcludePaths: cfg.classification.rank_exclude_paths,
|
|
823
1001
|
floor: cfg.tuning.irreversibility_floor,
|
|
824
1002
|
silence: cfg.tuning.silence_multiplier,
|
|
1003
|
+
tuning: { churn_window_days: cfg.tuning.churn_window_days },
|
|
1004
|
+
proof: cfg.proof,
|
|
825
1005
|
classification: {
|
|
826
1006
|
test_support_paths: cfg.classification.test_support_paths,
|
|
827
1007
|
scheduled_entry_paths: cfg.classification.scheduled_entry_paths,
|
|
@@ -11,12 +11,18 @@ import { join } from "node:path";
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
export const DEFAULT_RISK_CONFIG = {
|
|
13
13
|
classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [], rank_exclude_paths: [] },
|
|
14
|
-
tuning: { irreversibility_floor: true, silence_multiplier: true },
|
|
14
|
+
tuning: { irreversibility_floor: true, silence_multiplier: true, churn_window_days: 180 },
|
|
15
|
+
proof: { python_runner: "auto", attempt_limit: 20, baseline_green_target: 5 },
|
|
15
16
|
overrides: []
|
|
16
17
|
};
|
|
17
18
|
function asStringArray(v) {
|
|
18
19
|
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
19
20
|
}
|
|
21
|
+
function boundedInt(value, fallback, min, max) {
|
|
22
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
23
|
+
return fallback;
|
|
24
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
25
|
+
}
|
|
20
26
|
/** Glob → RegExp: `*` matches within a path segment, `**` matches across segments. */
|
|
21
27
|
export function globToRegExp(glob) {
|
|
22
28
|
const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
|
|
@@ -43,6 +49,16 @@ function applyFile(cfg, file, warnings, label) {
|
|
|
43
49
|
cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
|
|
44
50
|
if (typeof tun.silence_multiplier === "boolean")
|
|
45
51
|
cfg.tuning.silence_multiplier = tun.silence_multiplier;
|
|
52
|
+
if (tun.churn_window_days !== undefined)
|
|
53
|
+
cfg.tuning.churn_window_days = boundedInt(tun.churn_window_days, cfg.tuning.churn_window_days, 1, 3_650);
|
|
54
|
+
const proof = (raw.proof ?? {});
|
|
55
|
+
if (typeof proof.python_runner === "string" && proof.python_runner.trim() !== "") {
|
|
56
|
+
cfg.proof.python_runner = proof.python_runner.trim();
|
|
57
|
+
}
|
|
58
|
+
if (proof.attempt_limit !== undefined)
|
|
59
|
+
cfg.proof.attempt_limit = boundedInt(proof.attempt_limit, cfg.proof.attempt_limit, 1, 100);
|
|
60
|
+
if (proof.baseline_green_target !== undefined)
|
|
61
|
+
cfg.proof.baseline_green_target = boundedInt(proof.baseline_green_target, cfg.proof.baseline_green_target, 1, 100);
|
|
46
62
|
for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
|
|
47
63
|
const ov = o;
|
|
48
64
|
if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
|