knodin 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -157,7 +157,17 @@ function formatRepairHuman(result) {
157
157
  result.after.missing.files.length + result.after.missing.records.length;
158
158
  const firstIssue = result.after.missing.files[0] ?? result.after.missing.records[0];
159
159
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
160
- return `Repair finished with ${outstanding.toLocaleString()} remaining issue(s).${detail} Run \`knodin status --deep\` for details.\n`;
160
+ // Say when nothing moved: on nova the same 111-issue graph went through
161
+ // status -> repair -> status unchanged, and "Repair finished with 141
162
+ // remaining issue(s)" read as progress. Then print the audit's own remedy
163
+ // rather than a generic pointer; for a legacy epoch-less graph the audit
164
+ // already knows repair cannot fix it and index --clean can (KNODIN-50).
165
+ const unchanged = result.repaired.length === 0 &&
166
+ result.before.missing.files.length === result.after.missing.files.length &&
167
+ result.before.missing.records.length === result.after.missing.records.length;
168
+ const progress = unchanged ? "Repair made no changes; " : "Repair finished with ";
169
+ const remedy = result.after.repairSteps[0] ?? "Run `knodin status --deep` for details.";
170
+ return `${progress}${outstanding.toLocaleString()} remaining issue(s).${detail} ${remedy}\n`;
161
171
  }
162
172
  /**
163
173
  * The semantic gap, stated rather than left to be discovered.
@@ -198,7 +208,8 @@ function formatIndexVerificationError(result) {
198
208
  const condition = result.verification.issueCount > 0
199
209
  ? `${result.verification.issueCount.toLocaleString()} graph issue(s) remain`
200
210
  : `graph verification reported status "${result.verification.status}"`;
201
- return `knodin index: requested work completed, but ${condition}.${detail} Run \`knodin repair\`.\n`;
211
+ const remedy = result.verification.repairSteps[0] ?? "Run `knodin repair`.";
212
+ return `knodin index: requested work completed, but ${condition}.${detail} ${remedy}\n`;
202
213
  }
203
214
  /**
204
215
  * The hooks/lifecycle line of `status`.
@@ -501,7 +512,14 @@ function formatStatusHuman(result) {
501
512
  const lifecycleOnly = result.lifecycle?.status === "degraded" &&
502
513
  (result.coverage.countsUnknown || result.missing.files.length === 0) &&
503
514
  result.missing.records.every((record) => result.lifecycle?.issues.includes(record));
504
- const repairCommand = worktreeStep ?? (lifecycleOnly ? "Run `knodin repair --lifecycle`." : "Run `knodin repair`.");
515
+ // Otherwise the engine's first step is authoritative too: it distinguishes
516
+ // "rebuild damaged rows" from "this graph predates the content-proof epoch
517
+ // and only index --clean can certify it" — a case where sending the user to
518
+ // repair costs two failing round trips (KNODIN-50).
519
+ const repairCommand = worktreeStep ??
520
+ (lifecycleOnly
521
+ ? "Run `knodin repair --lifecycle`."
522
+ : (result.repairSteps?.[0] ?? "Run `knodin repair`."));
505
523
  return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
506
524
  }
507
525
  function humanLabel(key) {
@@ -2236,10 +2254,34 @@ async function main() {
2236
2254
  throw new Error("knodin hook-refresh: invalid lifecycle event");
2237
2255
  }
2238
2256
  const shared = await opportunisticSharedRestore();
2257
+ // Keep the audit that the scoped index already ran. Before this the
2258
+ // hook reported only the paths it wrote and exited 0, so a graph the
2259
+ // audit refused to certify — nova's 0.13.0 graph after the 0.13.1
2260
+ // upgrade, 33 commits behind with lifecycle "healthy" — looked like a
2261
+ // success on every commit (KNODIN-49).
2262
+ let verification;
2239
2263
  const indexed = shared?.restored
2240
2264
  ? []
2241
- : await refreshFromGitEvent(repo, event, (target, files) => engine.index(target, files));
2242
- result = { indexed, ...(shared ? { sharedRestore: shared } : {}) };
2265
+ : await refreshFromGitEvent(repo, event, async (target, files) => {
2266
+ const indexResult = await engine.index(target, files);
2267
+ verification = indexResult.verification;
2268
+ return indexResult;
2269
+ });
2270
+ if (verification?.status === "repair-needed") {
2271
+ const firstIssue = verification.missing.records[0] ?? verification.missing.files[0] ?? "unknown issue";
2272
+ const remedy = verification.repairSteps[0] ?? "Run `knodin repair`.";
2273
+ throw new Error(`knodin hook-refresh: indexed ${indexed.length} path(s) but the graph audit refused to certify the result (${verification.issueCount.toLocaleString()} issue(s); first: ${firstIssue}). ${remedy}`);
2274
+ }
2275
+ // `at` and `event` make each indexer.log record self-describing; the
2276
+ // background script supplies the queued event's sequence through
2277
+ // KNODIN_HOOK_EVENT_SEQUENCE so the line can be tied to its Git event.
2278
+ result = {
2279
+ at: new Date().toISOString(),
2280
+ event: { ...event, sequence: process.env.KNODIN_HOOK_EVENT_SEQUENCE ?? null },
2281
+ indexed,
2282
+ ...(verification ? { verification } : {}),
2283
+ ...(shared ? { sharedRestore: shared } : {}),
2284
+ };
2243
2285
  }
2244
2286
  finally {
2245
2287
  lifecycleLease.release();
@@ -2593,6 +2635,7 @@ async function main() {
2593
2635
  ? {
2594
2636
  mode: "source",
2595
2637
  identity: explained.identity,
2638
+ platformEntry: explained.platformEntry,
2596
2639
  symbol: explained.symbol,
2597
2640
  source: explained.source,
2598
2641
  staleness: explained.staleness,
@@ -3,10 +3,12 @@ import { canonicalCoveragePath } from "./engine/index-coverage.js";
3
3
  const PREFIX = "cc1";
4
4
  const DIGEST = /^[a-f0-9]{64}$/;
5
5
  const KINDS = [
6
+ "static_method_receiver",
6
7
  "static_field_read",
7
8
  "constructor",
8
9
  "variable_type",
9
10
  "cast",
11
+ "instanceof_type",
10
12
  "class_literal",
11
13
  "parameter_type",
12
14
  "generic_parameter_type",
@@ -87,10 +87,15 @@ export function compactExplainResult(result, selectorFile, byteBudget) {
87
87
  .map((line) => line.replace(/^\d+: /, ""))
88
88
  .join("\n");
89
89
  const header = `${freshness(result.staleness)} ${compactIdentity(result.identity)} ${selectorFile ?? "?"}:${firstLine || "?"}`;
90
- const output = `${header}\n${source}`;
90
+ const platform = result.platformEntry
91
+ ? `\nplatformEntry ${JSON.stringify(result.platformEntry)}`
92
+ : "";
93
+ const output = `${header}${platform}\n${source}`;
91
94
  if (byteBudget === undefined || Buffer.byteLength(output, "utf8") <= byteBudget)
92
95
  return output;
93
- const omitted = `${header}\nomitted ${Buffer.byteLength(source, "utf8")}B`;
96
+ const omitted = `${header}${platform}\nomitted ${Buffer.byteLength(source, "utf8")}B`;
97
+ if (platform && Buffer.byteLength(omitted, "utf8") > byteBudget)
98
+ throw new RangeError("explain budget cannot preserve platformEntry; increase the budget");
94
99
  return Buffer.byteLength(omitted, "utf8") <= byteBudget ? omitted : "";
95
100
  }
96
101
  function compactAmbiguity(candidates, staleness) {
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { deflateSync, inflateSync } from "node:zlib";
3
+ import { apexDeclaration } from "./apex-receiver.js";
2
4
  const fold = (value) => value.toLowerCase();
3
5
  const qualifiedIdentifier = /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/;
4
6
  const spelling = (node) => node.text.replace(/\s+/g, "");
@@ -122,7 +124,7 @@ export function extractApexClassUses(root, source, file) {
122
124
  }
123
125
  return null;
124
126
  };
125
- const add = (node, kind, typePath = spelling(node), memberName = null) => {
127
+ const add = (node, kind, typePath = spelling(node), memberName = null, arity) => {
126
128
  if (!qualifiedIdentifier.test(typePath))
127
129
  return;
128
130
  sites.push({
@@ -131,9 +133,10 @@ export function extractApexClassUses(root, source, file) {
131
133
  qualifiedSpelling: node.text,
132
134
  typePath,
133
135
  memberName,
136
+ ...(arity === undefined ? {} : { arity }),
134
137
  lexicalOwner: owner(node),
135
138
  ...range(node),
136
- blockedReason: kind === "static_field_read"
139
+ blockedReason: kind === "static_field_read" || kind === "static_method_receiver"
137
140
  ? blockReason(node, typePath)
138
141
  : malformed
139
142
  ? "malformed-source"
@@ -242,17 +245,37 @@ export function extractApexClassUses(root, source, file) {
242
245
  typeUses(node.childForFieldName("type"), "loop_variable_type");
243
246
  if (node.type === "cast_expression")
244
247
  typeUses(node.childForFieldName("type"), "cast");
248
+ if (node.type === "instanceof_expression")
249
+ typeUses(node.childForFieldName("right"), "instanceof_type");
245
250
  if (node.type === "object_creation_expression")
246
251
  typeUses(node.childForFieldName("type"), "constructor");
247
252
  if (node.type === "method_declaration") {
253
+ const name = node.childForFieldName("name");
254
+ const ownerPath = owner(node);
255
+ const metadata = apexDeclaration(node);
256
+ if (name && ownerPath && metadata.arity !== null)
257
+ declarations.push({
258
+ version: 1,
259
+ kind: "method",
260
+ ownerPath,
261
+ name: metadata.name,
262
+ arity: metadata.arity,
263
+ static: metadata.static,
264
+ annotations: metadata.annotations,
265
+ superclass: null,
266
+ ...range(name),
267
+ declarationRange: range(node),
268
+ });
248
269
  const type = node.childForFieldName("type");
249
270
  if (type)
250
271
  unsupported(type, "return_type");
251
272
  }
252
273
  if (node.type === "method_invocation") {
253
274
  const receiver = node.childForFieldName("object");
254
- if (receiver && qualifiedIdentifier.test(spelling(receiver)))
255
- unsupported(receiver, "static_method_receiver");
275
+ const name = node.childForFieldName("name");
276
+ const argumentsNode = node.childForFieldName("arguments");
277
+ if (receiver && name && argumentsNode && qualifiedIdentifier.test(spelling(receiver)))
278
+ add(receiver, "static_method_receiver", spelling(receiver), name.text, argumentsNode.namedChildren.filter((child) => !child.type.includes("comment")).length);
256
279
  }
257
280
  if (node.type !== "field_access" || node.parent?.type === "field_access")
258
281
  continue;
@@ -286,9 +309,22 @@ export function extractApexClassUses(root, source, file) {
286
309
  unsupportedUses,
287
310
  heritage,
288
311
  heritageComplete,
312
+ methodReceiverVersion: 1,
313
+ instanceofTypeVersion: 1,
289
314
  coverage: "supported-syntax-only",
290
315
  };
291
316
  }
317
+ /** Lossless, process-local retention only; persisted facts and binding evidence are unchanged. */
318
+ export function packApexConsumerCatalog(catalog) {
319
+ return deflateSync(JSON.stringify([Array.from(catalog.classes), Array.from(catalog.members)]), {
320
+ level: 1,
321
+ });
322
+ }
323
+ /** Only accepts buffers produced by this process, never user or database input. */
324
+ export function unpackApexConsumerCatalog(packed) {
325
+ const [classes, members] = JSON.parse(inflateSync(packed).toString("utf8"));
326
+ return { classes: new Map(classes), members: new Map(members) };
327
+ }
292
328
  /** Build once per indexed catalog revision, never once per use or by walking the filesystem. */
293
329
  export function createApexConsumerCatalog(files) {
294
330
  const classes = new Map();
@@ -376,8 +412,10 @@ export function resolveApexClassUses(catalog, facts) {
376
412
  parents[0].declaration.kind !== "class")
377
413
  return result("blocked");
378
414
  const ancestorPath = parents[0].declaration.ownerPath;
379
- if (site.kind === "static_field_read" &&
380
- catalog.members.has(fold(`${ancestorPath}.${first}`)))
415
+ if ((site.kind === "static_field_read" || site.kind === "static_method_receiver") &&
416
+ catalog.members
417
+ .get(fold(`${ancestorPath}.${first}`))
418
+ ?.some((entry) => entry.declaration.kind !== "method"))
381
419
  return result("blocked");
382
420
  const inheritedPrefix = catalog.classes.get(fold(`${ancestorPath}.${first}`));
383
421
  if (inheritedPrefix?.length) {
@@ -409,14 +447,22 @@ export function resolveApexClassUses(catalog, facts) {
409
447
  !["class", "interface"].includes(target.declaration.kind))
410
448
  return result("blocked");
411
449
  if (target.declaration.kind === "interface" &&
412
- (site.kind === "constructor" || site.kind === "static_field_read"))
450
+ (site.kind === "constructor" ||
451
+ site.kind === "static_field_read" ||
452
+ site.kind === "static_method_receiver"))
413
453
  return result("blocked");
414
454
  // Every owning class must be unique too: nested declarations cannot bypass an outer collision.
415
455
  if (!uniqueOwner(target))
416
456
  return result("ambiguous");
417
457
  if (!site.memberName)
418
458
  return result("resolved", target);
419
- const members = catalog.members.get(fold(`${target.declaration.ownerPath}.${site.memberName}`));
459
+ const members = catalog.members
460
+ .get(fold(`${target.declaration.ownerPath}.${site.memberName}`))
461
+ ?.filter((entry) => site.kind === "static_method_receiver"
462
+ ? entry.declaration.kind === "method" &&
463
+ site.arity !== undefined &&
464
+ entry.declaration.arity === site.arity
465
+ : entry.declaration.kind !== "method");
420
466
  if (!members?.length)
421
467
  return result("missing");
422
468
  if (members.length !== 1)
@@ -0,0 +1,368 @@
1
+ import { createHash } from "node:crypto";
2
+ import { compareBytes } from "../compare.js";
3
+ import { apexPlatformEntryPoints } from "./apex-entry-points.js";
4
+ import { apexReferenceReadGuard, parseApexCall, parseApexDeclaration } from "./apex-receiver.js";
5
+ /** Conditional indexed-source hypotheses; never a closed-world liveness analysis. */
6
+ export function apexDeadChains(db, isTest) {
7
+ const analysis = {
8
+ version: 1,
9
+ model: "apex-indexed-roots-v1",
10
+ scope: "indexed-source",
11
+ runtimeInvocation: "not-established",
12
+ safeToDelete: false,
13
+ roots: 0,
14
+ exactEdges: 0,
15
+ possibleEdges: 0,
16
+ unknownCalls: 0,
17
+ partial: false,
18
+ chains: [],
19
+ limitations: [
20
+ "Only modeled roots and calls in the indexed source inventory are considered; omitted callers, org configuration and runtime dispatch are not established.",
21
+ "Chain owners describe structural membership, not class-wide nonuse. Candidates are not permission to delete source.",
22
+ ],
23
+ };
24
+ const candidates = new Map();
25
+ const columns = (table) => new Set(db
26
+ .query(`PRAGMA table_info("${table}")`)
27
+ .all()
28
+ .map((row) => row.name));
29
+ const symbolColumns = columns("symbols");
30
+ const referenceColumns = columns("references");
31
+ if (!symbolColumns.has("apexDeclaration") ||
32
+ !symbolColumns.has("identity") ||
33
+ ["apexCall", "callerIdentity", "calleeIdentity", "column"].some((column) => !referenceColumns.has(column))) {
34
+ analysis.partial = true;
35
+ analysis.limitations.push("Legacy graph lacks root-analysis provenance; repair the indexed Apex scope.");
36
+ return { candidates, protected: new Set(), analysis };
37
+ }
38
+ if (!db
39
+ .query("SELECT count(*) count FROM symbols WHERE filePath LIKE '%.cls'")
40
+ .get()?.count)
41
+ return { candidates, protected: new Set(), analysis };
42
+ const definitions = db
43
+ .query("SELECT id,identity,name,kind,filePath,startLine,endLine,startCol,endCol,apexDeclaration FROM symbols LIMIT 50001")
44
+ .all();
45
+ const refs = db
46
+ .query('SELECT callerSymbol,callerFile,callerIdentity,calleeSymbol,calleeFile,calleeIdentity,line,column,kind,apexCall FROM "references" LIMIT 500001')
47
+ .all();
48
+ if (definitions.length > 50000 || refs.length > 500000) {
49
+ analysis.partial = true;
50
+ analysis.limitations.push("Root analysis inventory exceeds its bounded node/edge limit; no chain candidates were established.");
51
+ return { candidates, protected: new Set(), analysis };
52
+ }
53
+ const byIdentity = new Map(definitions.filter((d) => d.identity).map((d) => [d.identity, d]));
54
+ const apex = definitions.filter((d) => /\.cls$/i.test(d.filePath) && ["class", "method"].includes(d.kind));
55
+ const parsed = new Map(apex.map((d) => [d.id, parseApexDeclaration(d.apexDeclaration)]));
56
+ const testFiles = new Set(apex
57
+ .filter((d) => parsed
58
+ .get(d.id)
59
+ ?.annotations.some((name) => name.replace(/^@/, "").toLowerCase() === "istest"))
60
+ .map((d) => d.filePath));
61
+ const testFile = (file) => isTest(file) || testFiles.has(file);
62
+ const invalidFiles = new Set(apex.filter((d) => !parsed.get(d.id) || !d.identity).map((d) => d.filePath));
63
+ const seenIdentities = new Map();
64
+ for (const d of definitions) {
65
+ if (!d.identity)
66
+ continue;
67
+ const previous = seenIdentities.get(d.identity);
68
+ if (previous !== undefined) {
69
+ invalidFiles.add(previous);
70
+ invalidFiles.add(d.filePath);
71
+ }
72
+ else
73
+ seenIdentities.set(d.identity, d.filePath);
74
+ }
75
+ const tables = new Set(db
76
+ .query("SELECT name FROM sqlite_master WHERE type='table'")
77
+ .all()
78
+ .map((row) => row.name));
79
+ const capabilities = tables.has("apex_file_capabilities")
80
+ ? new Map(db
81
+ .query("SELECT filePath,version FROM apex_file_capabilities")
82
+ .all()
83
+ .map((row) => [row.filePath, row.version]))
84
+ : new Map();
85
+ for (const d of apex)
86
+ if (capabilities.get(d.filePath) !== 1)
87
+ invalidFiles.add(d.filePath);
88
+ if (tables.has("apex_consumer_files"))
89
+ for (const row of db
90
+ .query("SELECT filePath FROM apex_consumer_files WHERE parseState<>'complete'")
91
+ .all())
92
+ invalidFiles.add(row.filePath);
93
+ const byFile = new Map();
94
+ const byName = new Map();
95
+ const classesByOwner = new Map();
96
+ const group = (map, key, value) => {
97
+ const list = map.get(key) ?? [];
98
+ list.push(value);
99
+ map.set(key, list);
100
+ };
101
+ for (const d of definitions) {
102
+ group(byFile, d.filePath, d);
103
+ if (d.kind === "method")
104
+ group(byName, d.name.toLowerCase(), d);
105
+ const declaration = parsed.get(d.id);
106
+ if (d.kind === "class" && declaration)
107
+ group(classesByOwner, declaration.owner.toLowerCase(), d);
108
+ }
109
+ const roots = new Set();
110
+ const exact = new Map();
111
+ const possible = new Map();
112
+ const callEdges = [];
113
+ const ownerByMethod = new Map();
114
+ const wildcardCallers = new Set();
115
+ const add = (map, from, to) => {
116
+ const targets = map.get(from) ?? new Set();
117
+ targets.add(to);
118
+ map.set(from, targets);
119
+ };
120
+ const entryRows = apex.flatMap((d) => {
121
+ const declaration = parsed.get(d.id);
122
+ return declaration && d.identity ? [{ key: d.identity, file: d.filePath, declaration }] : [];
123
+ });
124
+ for (const key of apexPlatformEntryPoints(entryRows).keys())
125
+ roots.add(key);
126
+ for (const d of definitions) {
127
+ if (!d.identity)
128
+ continue;
129
+ if (testFile(d.filePath) ||
130
+ /\.trigger$/i.test(d.filePath) ||
131
+ invalidFiles.has(d.filePath) ||
132
+ parsed.get(d.id)?.modifiers.some((value) => value.toLowerCase() === "testmethod"))
133
+ roots.add(d.identity);
134
+ const declaration = parsed.get(d.id);
135
+ if (d.kind !== "method" || !declaration)
136
+ continue;
137
+ const owners = (classesByOwner.get(declaration.owner.toLowerCase()) ?? []).filter((owner) => owner.kind === "class" &&
138
+ parsed.get(owner.id)?.owner.toLowerCase() === declaration.owner.toLowerCase());
139
+ if (owners.length !== 1 || !owners[0].identity) {
140
+ roots.add(d.identity);
141
+ analysis.partial = true;
142
+ continue;
143
+ }
144
+ ownerByMethod.set(d.identity, owners[0]);
145
+ // Invoking a method can activate its owner initializer; membership alone does not invoke siblings.
146
+ add(possible, d.identity, owners[0].identity);
147
+ if (d.name.toLowerCase() === owners[0].name.toLowerCase() || d.name === "constructor")
148
+ add(possible, owners[0].identity, d.identity);
149
+ }
150
+ const contains = (d, ref) => (d.startLine < ref.line || (d.startLine === ref.line && d.startCol <= ref.column)) &&
151
+ (d.endLine > ref.line || (d.endLine === ref.line && d.endCol > ref.column));
152
+ const guard = apexReferenceReadGuard(db);
153
+ for (const ref of refs) {
154
+ const knownTarget = ref.calleeIdentity ? byIdentity.get(ref.calleeIdentity) : undefined;
155
+ if (!/\.(cls|trigger)$/i.test(ref.callerFile)) {
156
+ // A known foreign-source/metadata binding is a conservative boundary, not a proven runtime call.
157
+ if (knownTarget?.identity)
158
+ roots.add(knownTarget.identity);
159
+ else if (ref.calleeFile && /\.cls$/i.test(ref.calleeFile)) {
160
+ for (const d of byFile.get(ref.calleeFile) ?? [])
161
+ if (d.identity)
162
+ roots.add(d.identity);
163
+ analysis.partial = true;
164
+ }
165
+ else {
166
+ const name = ref.calleeSymbol.split(".").at(-1)?.toLowerCase() ?? "";
167
+ const potential = [
168
+ ...(byName.get(name) ?? []),
169
+ ...(classesByOwner.get(ref.calleeSymbol.toLowerCase()) ?? []),
170
+ ];
171
+ for (const d of potential)
172
+ if (d.identity)
173
+ roots.add(d.identity);
174
+ if (potential.length)
175
+ analysis.partial = true;
176
+ }
177
+ continue;
178
+ }
179
+ if (ref.kind !== "call")
180
+ continue;
181
+ const fileDefinitions = byFile.get(ref.callerFile) ?? [];
182
+ const methods = fileDefinitions.filter((d) => d.kind === "method" && contains(d, ref));
183
+ let callers = methods;
184
+ if (!callers.length) {
185
+ const owners = fileDefinitions.filter((d) => ["class", "trigger"].includes(d.kind) && contains(d, ref));
186
+ const innermost = owners.filter((owner) => !owners.some((other) => other.id !== owner.id &&
187
+ other.startLine >= owner.startLine &&
188
+ other.endLine <= owner.endLine &&
189
+ (other.startLine > owner.startLine || other.endLine < owner.endLine)));
190
+ callers = innermost;
191
+ }
192
+ const exactCaller = callers.length === 1 &&
193
+ callers[0].identity === ref.callerIdentity &&
194
+ callers[0].name === ref.callerSymbol;
195
+ const call = parseApexCall(ref.apexCall);
196
+ let targets = [];
197
+ let exactTarget = false;
198
+ if (ref.apexCall && call) {
199
+ const ownerCandidates = classesByOwner.get(call.receiver.toLowerCase()) ?? [];
200
+ const localMembers = call.kind === "constructor"
201
+ ? ownerCandidates
202
+ : (byName.get(call.member.toLowerCase()) ?? []).filter((d) => parsed.get(d.id)?.arity === call.arity);
203
+ targets = call.blocked
204
+ ? localMembers
205
+ : localMembers.filter((d) => parsed.get(d.id)?.owner.toLowerCase() === call.receiver.toLowerCase());
206
+ if (!targets.length && call.kind !== "constructor")
207
+ targets = localMembers;
208
+ exactTarget =
209
+ ownerCandidates.length === 1 &&
210
+ targets.length === 1 &&
211
+ targets[0].identity === ref.calleeIdentity &&
212
+ guard(ref);
213
+ }
214
+ else if (!ref.apexCall) {
215
+ const local = (byName.get(ref.calleeSymbol.toLowerCase()) ?? []).filter((d) => d.filePath === ref.callerFile);
216
+ const callerOwners = new Set(callers.map((d) => parsed.get(d.id)?.owner.toLowerCase()).filter(Boolean));
217
+ targets = local.filter((d) => callerOwners.has(parsed.get(d.id)?.owner.toLowerCase()));
218
+ if (!targets.length)
219
+ targets = byName.get(ref.calleeSymbol.toLowerCase()) ?? [];
220
+ exactTarget =
221
+ targets.length === 1 &&
222
+ targets[0].identity === ref.calleeIdentity &&
223
+ targets[0].filePath === ref.calleeFile;
224
+ }
225
+ else {
226
+ targets = byName.get(ref.calleeSymbol.toLowerCase()) ?? [];
227
+ }
228
+ if (!exactCaller || !exactTarget)
229
+ analysis.unknownCalls++;
230
+ // Reflection or corrupt provenance can address targets not enumerable by a literal member name.
231
+ const reflectiveTypeLookup = call?.kind === "static" &&
232
+ /^(?:System\.)?Type$/i.test(call.receiver) &&
233
+ /^forName$/i.test(call.member) &&
234
+ !exactTarget;
235
+ // An unresolved value's newInstance may be Type.newInstance; ordinary static
236
+ // Date/Datetime factories and resolved local service invoke methods are not reflection.
237
+ const unknownTypeInstance = call?.kind === "static" && call.blocked && /^newInstance$/i.test(call.member) && !exactTarget;
238
+ const wildcard = (!!ref.apexCall && !call) || reflectiveTypeLookup || unknownTypeInstance;
239
+ if (wildcard) {
240
+ if (!callers.length)
241
+ for (const d of apex)
242
+ if (d.identity)
243
+ roots.add(d.identity);
244
+ for (const caller of callers)
245
+ if (caller.identity)
246
+ wildcardCallers.add(caller.identity);
247
+ }
248
+ for (const target of targets) {
249
+ if (!target.identity)
250
+ continue;
251
+ if (!callers.length) {
252
+ roots.add(target.identity);
253
+ analysis.partial = true;
254
+ }
255
+ for (const caller of callers) {
256
+ if (!caller.identity) {
257
+ roots.add(target.identity);
258
+ analysis.partial = true;
259
+ continue;
260
+ }
261
+ const proven = exactCaller && exactTarget;
262
+ add(proven ? exact : possible, caller.identity, target.identity);
263
+ if (proven && caller.kind === "method" && target.kind === "method")
264
+ callEdges.push({
265
+ fromIdentity: caller.identity,
266
+ toIdentity: target.identity,
267
+ file: ref.callerFile,
268
+ line: ref.line,
269
+ column: ref.column + 1,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ // Metadata with only a file-level target is uncertain: protect the entire addressed Apex file.
275
+ for (const dep of db
276
+ .query("SELECT fromFile,toFile FROM dependencies")
277
+ .all()) {
278
+ if (/\.(cls|trigger)$/i.test(dep.fromFile) || !/\.cls$/i.test(dep.toFile))
279
+ continue;
280
+ for (const d of byFile.get(dep.toFile) ?? [])
281
+ if (d.identity)
282
+ roots.add(d.identity);
283
+ }
284
+ const reached = new Set(roots);
285
+ const queue = [...roots];
286
+ let wildcardExpanded = false;
287
+ // The array iterator sees elements pushed while iterating, so this walks the
288
+ // whole reachable set as a queue.
289
+ for (const current of queue) {
290
+ if (wildcardCallers.has(current)) {
291
+ if (!wildcardExpanded)
292
+ for (const d of apex)
293
+ if (d.identity && !reached.has(d.identity)) {
294
+ reached.add(d.identity);
295
+ queue.push(d.identity);
296
+ }
297
+ analysis.partial = true;
298
+ wildcardExpanded = true;
299
+ }
300
+ for (const next of [...(exact.get(current) ?? []), ...(possible.get(current) ?? [])])
301
+ if (!reached.has(next)) {
302
+ reached.add(next);
303
+ queue.push(next);
304
+ }
305
+ }
306
+ const adjacency = new Map();
307
+ for (const edge of callEdges) {
308
+ if (reached.has(edge.fromIdentity) || reached.has(edge.toIdentity))
309
+ continue;
310
+ add(adjacency, edge.fromIdentity, edge.toIdentity);
311
+ add(adjacency, edge.toIdentity, edge.fromIdentity);
312
+ }
313
+ const visited = new Set();
314
+ const edgesByCaller = new Map();
315
+ for (const edge of callEdges) {
316
+ const list = edgesByCaller.get(edge.fromIdentity) ?? [];
317
+ list.push(edge);
318
+ edgesByCaller.set(edge.fromIdentity, list);
319
+ }
320
+ const member = (d) => ({
321
+ identity: d.identity,
322
+ symbol: d.name,
323
+ file: d.filePath,
324
+ line: d.startLine,
325
+ kind: d.kind,
326
+ });
327
+ for (const start of [...adjacency.keys()].sort(compareBytes)) {
328
+ if (visited.has(start))
329
+ continue;
330
+ const component = [start];
331
+ visited.add(start);
332
+ for (const member of component)
333
+ for (const next of adjacency.get(member) ?? [])
334
+ if (!visited.has(next)) {
335
+ visited.add(next);
336
+ component.push(next);
337
+ }
338
+ const members = component
339
+ .map((id) => byIdentity.get(id))
340
+ .filter((d) => !!d && d.kind === "method" && !invalidFiles.has(d.filePath) && !testFile(d.filePath));
341
+ if (!members.length || members.length !== component.length)
342
+ continue;
343
+ const ids = new Set(component);
344
+ const identity = `chain_${createHash("sha256")
345
+ .update([...ids].sort(compareBytes).join("\0"))
346
+ .digest("hex")
347
+ .slice(0, 24)}`;
348
+ const owners = new Map(members.flatMap((d) => {
349
+ const owner = ownerByMethod.get(d.identity);
350
+ return owner?.identity ? [[owner.identity, owner]] : [];
351
+ }));
352
+ analysis.chains.push({
353
+ identity,
354
+ members: members.map(member).sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
355
+ owners: [...owners.values()].map(member),
356
+ edges: component
357
+ .flatMap((id) => edgesByCaller.get(id) ?? [])
358
+ .filter((edge) => ids.has(edge.toIdentity)),
359
+ });
360
+ for (const d of members)
361
+ candidates.set(d.identity, identity);
362
+ }
363
+ analysis.roots = roots.size;
364
+ analysis.exactEdges = [...exact.values()].reduce((sum, targets) => sum + targets.size, 0);
365
+ analysis.possibleEdges = [...possible.values()].reduce((sum, targets) => sum + targets.size, 0);
366
+ analysis.partial ||= invalidFiles.size > 0 || analysis.unknownCalls > 0;
367
+ return { candidates, protected: reached, analysis };
368
+ }