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 +48 -5
- package/dist/src/class-consumer-cursor.js +2 -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/index.js +368 -21
- package/dist/src/engine/seal.js +28 -4
- package/dist/src/init.js +14 -2
- package/dist/src/response-budget.js +14 -2
- package/dist/src/tools/knodin-tools.js +1 -0
- package/dist/src/worktree-seed.js +28 -1
- package/docs/CLI.md +6 -3
- package/docs/DEAD-CODE-AND-IMPACT.md +41 -0
- package/docs/MCP.md +7 -3
- package/docs/SALESFORCE-BINDINGS.md +26 -8
- package/docs/releases/0.14.0.md +78 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
2242
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|