@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.
@@ -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
- const GIT_CHURN_BATCH = 200;
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 unavailableWindow = churnWindow ?? "180 days before HEAD";
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: "source root unavailable" };
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", "ignore"], timeout: 4000 }).trim();
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 - 180 * 24 * 60 * 60 * 1000).toISOString() : unavailableWindow);
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 Git history cannot support a complete churn window"
246
+ ? "shallow clone: churn based on available history"
108
247
  : partial
109
- ? "partial-clone Git objects cannot guarantee a complete offline churn window"
248
+ ? "partial clone: churn based on available history"
110
249
  : undefined
111
250
  };
112
251
  }
113
- catch {
114
- return { sourceRoot: root, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnAvailable: false, reason: "Git history could not be read from the analyzed source root" };
252
+ catch (error) {
253
+ return unavailableHealth(root, unavailableWindow, error);
115
254
  }
116
255
  }
117
- function gitChurn(root, files, window) {
118
- const out = new Map();
119
- if (!root || files.length === 0)
120
- return { values: out, complete: Boolean(root) };
121
- for (let i = 0; i < files.length; i += GIT_CHURN_BATCH) {
122
- const batch = files.slice(i, i + GIT_CHURN_BATCH);
123
- try {
124
- const stdout = execFileSync("git", ["log", `--since=${window}`, "--numstat", "--", ...batch], {
125
- cwd: root,
126
- encoding: "utf8",
127
- stdio: ["ignore", "pipe", "ignore"],
128
- timeout: 4000,
129
- maxBuffer: 2_000_000
130
- });
131
- for (const line of stdout.split("\n")) {
132
- const m = line.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/);
133
- if (!m)
134
- continue;
135
- const adds = m[1] === "-" ? 0 : Number(m[1]);
136
- const dels = m[2] === "-" ? 0 : Number(m[2]);
137
- out.set(m[3], (out.get(m[3]) ?? 0) + adds + dels);
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
- catch {
141
- return { values: new Map(), complete: false };
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
- return { values: out, complete: true };
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 += GIT_CHURN_BATCH) {
151
- const batch = files.slice(i, i + GIT_CHURN_BATCH);
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: 4000,
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
- continue;
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
- if (!health.churnAvailable) {
188
- return hashString(JSON.stringify({ state: health.history, window: health.churnWindow, available: false }));
189
- }
190
- const churn = gitChurn(root, sortedFiles, health.churnWindow);
191
- const first = churn.complete ? gitFirstCommitBatch(root, sortedFiles) : new Map();
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: health.history,
359
+ state: churn.state,
194
360
  window: health.churnWindow,
195
- available: churn.complete,
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 = inputHealth.churnAvailable ? gitChurn(repoRoot, files, churnWindow) : { values: new Map(), complete: false };
637
+ const churnResult = gitChurn(repoRoot, files, churnWindow, inputHealth);
471
638
  const churn = churnResult.values;
472
- const churnAvailable = inputHealth.churnAvailable && churnResult.complete;
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
- // Method-level attribution. CALLS edges are already symbol-granular and count
478
- // at full weight. IMPORTS edges are file-granular: previously every symbol in
479
- // an imported file inherited the file's full import count, which made all 17
480
- // methods of a hot service tie at the same "incoming refs" and saturated the
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" && symbolsByFile.has(e.to_external_id)) {
489
- fileImports.set(e.to_external_id, (fileImports.get(e.to_external_id) ?? 0) + 1);
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) && (DESTRUCTIVE_CALL_RE.test(lastSeg(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) && (DESTRUCTIVE_CALL_RE.test(lastSeg(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
- // Preserve the legacy option's historical evidence-filtered attribution.
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 = legacyIncoming.get(s.external_id) ?? 0;
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 180 days${git_churn > 500 ? " (score capped at 500)" : ""}`
619
- : "Git churn unavailable — provisional static-only ranking"
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"} (method-attributed)`,
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 180 days`
686
- : "Git churn unavailable — provisional static-only ranking",
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 ?? "")) {