@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.
@@ -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
- const GIT_CHURN_BATCH = 200;
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 unavailableWindow = churnWindow ?? "180 days before HEAD";
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: "source root unavailable" };
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", "ignore"], timeout: 4000 }).trim();
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 - 180 * 24 * 60 * 60 * 1000).toISOString() : unavailableWindow);
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 Git history cannot support a complete churn window"
249
+ ? "shallow clone: churn based on available history"
108
250
  : partial
109
- ? "partial-clone Git objects cannot guarantee a complete offline churn window"
251
+ ? "partial clone: churn based on available history"
110
252
  : undefined
111
253
  };
112
254
  }
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" };
255
+ catch (error) {
256
+ return unavailableHealth(root, unavailableWindow, error);
115
257
  }
116
258
  }
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);
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
- catch {
141
- return { values: new Map(), complete: false };
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
- return { values: out, complete: true };
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 += GIT_CHURN_BATCH) {
151
- const batch = files.slice(i, i + GIT_CHURN_BATCH);
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: 4000,
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
- continue;
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
- 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();
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: health.history,
367
+ state: churn.state,
194
368
  window: health.churnWindow,
195
- available: churn.complete,
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 = inputHealth.churnAvailable ? gitChurn(repoRoot, files, churnWindow) : { values: new Map(), complete: false };
649
+ const churnResult = gitChurn(repoRoot, files, churnWindow, inputHealth);
471
650
  const churn = churnResult.values;
472
- const churnAvailable = inputHealth.churnAvailable && churnResult.complete;
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
- // 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.
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
- const fileImports = new Map();
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
- incoming.set(e.to_external_id, (incoming.get(e.to_external_id) ?? 0) + 1);
672
+ addIncoming(e.to_external_id, "calls", 1);
487
673
  }
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);
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
- 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
- }
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 destructive = ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
547
- (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
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) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
767
+ return importedStatic.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
552
768
  IMPORTED_REPLACEMENT_CALL_RE.test(lastSeg(c)) ||
553
- (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
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 sinkReached = new Map(normalizationSymbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
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
- // 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
- }
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 = legacyIncoming.get(s.external_id) ?? 0;
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 180 days${git_churn > 500 ? " (score capped at 500)" : ""}`
619
- : "Git churn unavailable — provisional static-only ranking"
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
- const disconnected = incoming_refs === 0 && fan_out === 0;
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"} (method-attributed)`,
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 180 days`
686
- : "Git churn unavailable — provisional static-only ranking",
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
- ...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
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,