knodin 0.13.0 → 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.
@@ -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
+ }
@@ -35,9 +35,26 @@ function callbackSignature(contract, declaration) {
35
35
  }
36
36
  return false;
37
37
  }
38
+ const contractNames = new Map([
39
+ ["queueable", "Queueable"],
40
+ ["system.queueable", "System.Queueable"],
41
+ ["database.batchable", "Database.Batchable"],
42
+ ["schedulable", "Schedulable"],
43
+ ["system.schedulable", "System.Schedulable"],
44
+ ["messaging.inboundemailhandler", "Messaging.InboundEmailHandler"],
45
+ ]);
46
+ const contractName = (name) => contractNames.get(normalized(name)) ?? name;
38
47
  /** Static org-facing declarations, never a claim that an org actually invokes them. */
39
- export function apexOrgEntryPoints(rows) {
48
+ export function apexPlatformEntryPoints(rows) {
40
49
  const protectedSymbols = new Map();
50
+ const protect = (key, reason, contracts, owner) => {
51
+ const previous = protectedSymbols.get(key);
52
+ protectedSymbols.set(key, {
53
+ reason,
54
+ contracts: [...new Set([...(previous?.contracts ?? []), ...contracts])],
55
+ ...(owner ? { ownerKey: owner } : {}),
56
+ });
57
+ };
41
58
  const classes = new Map();
42
59
  for (const row of rows) {
43
60
  const decl = row.declaration;
@@ -46,9 +63,9 @@ export function apexOrgEntryPoints(rows) {
46
63
  const key = ownerKey(row);
47
64
  classes.set(key, [...(classes.get(key) ?? []), row]);
48
65
  if (decl.annotations.some((name) => normalized(name) === "restresource"))
49
- protectedSymbols.set(row.key, "REST resource declaration");
66
+ protect(row.key, "REST resource declaration", ["@RestResource"]);
50
67
  if (decl.implements.some((name) => callbacks.has(normalized(name))))
51
- protectedSymbols.set(row.key, "Salesforce platform callback contract");
68
+ protect(row.key, "Salesforce platform callback contract", decl.implements.filter((name) => callbacks.has(normalized(name))).map(contractName));
52
69
  }
53
70
  for (const row of rows) {
54
71
  const decl = row.declaration;
@@ -62,6 +79,15 @@ export function apexOrgEntryPoints(rows) {
62
79
  const annotations = new Set(decl.annotations.map(normalized));
63
80
  const modifiers = new Set(decl.modifiers.map(normalized));
64
81
  let reason;
82
+ const contracts = [];
83
+ if (annotations.has("invocablemethod") && decl.static)
84
+ contracts.push("@InvocableMethod");
85
+ if (annotations.has("auraenabled") && decl.static)
86
+ contracts.push("@AuraEnabled");
87
+ if (annotations.has("future") && decl.static)
88
+ contracts.push("@future");
89
+ if (modifiers.has("webservice") && decl.static)
90
+ contracts.push("webservice");
65
91
  if (annotations.has("invocablemethod") && decl.static)
66
92
  reason = "Invocable Apex declaration";
67
93
  if (annotations.has("auraenabled") && decl.static)
@@ -72,15 +98,21 @@ export function apexOrgEntryPoints(rows) {
72
98
  reason = "SOAP Apex declaration";
73
99
  if (decl.static &&
74
100
  owner.declaration.annotations.some((name) => normalized(name) === "restresource") &&
75
- ["httpget", "httppost", "httpput", "httppatch", "httpdelete"].some((name) => annotations.has(name)))
101
+ ["httpget", "httppost", "httpput", "httppatch", "httpdelete"].some((name) => annotations.has(name))) {
76
102
  reason = "REST Apex method declaration";
103
+ contracts.push("@RestResource");
104
+ }
77
105
  if (!decl.static &&
78
106
  (modifiers.has("public") || modifiers.has("global")) &&
79
- owner.declaration.implements.some((name) => callbackSignature(normalized(name), decl)))
107
+ owner.declaration.implements.some((name) => callbackSignature(normalized(name), decl))) {
80
108
  reason = "Salesforce platform callback method";
109
+ contracts.push(...owner.declaration.implements
110
+ .filter((name) => callbackSignature(normalized(name), decl))
111
+ .map(contractName));
112
+ }
81
113
  if (reason) {
82
- protectedSymbols.set(row.key, reason);
83
- protectedSymbols.set(owner.key, "Owner of an org-facing Apex declaration");
114
+ protect(row.key, reason, contracts, owner.key);
115
+ protect(owner.key, "Owner of an org-facing Apex declaration", contracts);
84
116
  }
85
117
  }
86
118
  // A constructor of an org-facing class is a boundary, not an ordinary helper.
@@ -92,7 +124,11 @@ export function apexOrgEntryPoints(rows) {
92
124
  if (owners.length === 1 &&
93
125
  protectedSymbols.has(owners[0].key) &&
94
126
  normalized(decl.name) === normalized(owners[0].declaration.name))
95
- protectedSymbols.set(row.key, "Constructor of an org-facing Apex class");
127
+ protect(row.key, "Constructor of an org-facing Apex class", protectedSymbols.get(owners[0].key)?.contracts ?? [], owners[0].key);
96
128
  }
97
129
  return protectedSymbols;
98
130
  }
131
+ /** Compatibility projection used by dead-code exclusion; reasons retain their original precedence. */
132
+ export function apexOrgEntryPoints(rows) {
133
+ return new Map([...apexPlatformEntryPoints(rows)].map(([key, entry]) => [key, entry.reason]));
134
+ }