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.
- package/dist/bin/cli.js +120 -9
- package/dist/src/backup-retention.js +345 -12
- package/dist/src/class-consumer-cursor.js +2 -0
- package/dist/src/cli-model.js +12 -0
- package/dist/src/compact-structural.js +7 -2
- package/dist/src/engine/apex-class-uses.js +54 -8
- package/dist/src/engine/apex-dead-chains.js +368 -0
- package/dist/src/engine/apex-entry-points.js +44 -8
- package/dist/src/engine/candidate-database.js +624 -66
- package/dist/src/engine/index.js +1768 -258
- package/dist/src/engine/seal.js +28 -4
- package/dist/src/engine/state-paths.js +192 -31
- package/dist/src/init.js +14 -2
- package/dist/src/manager-update.js +9 -10
- package/dist/src/mirror.js +2 -0
- package/dist/src/response-budget.js +14 -2
- package/dist/src/shared-index/restore.js +16 -6
- package/dist/src/storage-budget.js +146 -0
- package/dist/src/storage-inventory.js +263 -0
- package/dist/src/storage-management.js +204 -0
- package/dist/src/storage-policy-contract.js +121 -0
- package/dist/src/tools/knodin-tools.js +1 -0
- package/dist/src/worktree-seed.js +35 -2
- package/docs/BACKUP-RETENTION.md +46 -2
- package/docs/CLI.md +12 -5
- package/docs/DEAD-CODE-AND-IMPACT.md +41 -0
- package/docs/MCP.md +15 -3
- package/docs/SALESFORCE-BINDINGS.md +26 -8
- package/docs/releases/0.13.1.md +216 -0
- package/docs/releases/0.14.0.md +78 -0
- package/package.json +4 -1
|
@@ -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
|
-
|
|
255
|
-
|
|
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
|
|
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" ||
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
+
protect(row.key, "REST resource declaration", ["@RestResource"]);
|
|
50
67
|
if (decl.implements.some((name) => callbacks.has(normalized(name))))
|
|
51
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
+
}
|