al-sem 0.0.1
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/LICENSE +21 -0
- package/README.md +361 -0
- package/package.json +64 -0
- package/scripts/d40-diff.ts +44 -0
- package/scripts/fetch-native-parser.ts +179 -0
- package/scripts/precision-sample.ts +99 -0
- package/scripts/precision-study.ts +42 -0
- package/scripts/precision-tabulate.ts +52 -0
- package/src/cli/baseline.ts +31 -0
- package/src/cli/diff.ts +199 -0
- package/src/cli/events-chains.ts +56 -0
- package/src/cli/events-fanout.ts +87 -0
- package/src/cli/exit-code.ts +30 -0
- package/src/cli/fingerprint-indexes.ts +130 -0
- package/src/cli/fingerprint-query.ts +543 -0
- package/src/cli/fingerprint-witness.ts +493 -0
- package/src/cli/fingerprint.ts +292 -0
- package/src/cli/format-compact-json.ts +45 -0
- package/src/cli/format-events.ts +77 -0
- package/src/cli/format-fingerprint.ts +295 -0
- package/src/cli/format-html.ts +503 -0
- package/src/cli/format-json.ts +13 -0
- package/src/cli/format-policy.ts +95 -0
- package/src/cli/format-sarif.ts +186 -0
- package/src/cli/format-terminal.ts +153 -0
- package/src/cli/index.ts +566 -0
- package/src/cli/policy.ts +204 -0
- package/src/config/roots-config.ts +302 -0
- package/src/deps/cache-versions.ts +74 -0
- package/src/deps/canonical-json.ts +27 -0
- package/src/deps/dependency-artifact.ts +144 -0
- package/src/deps/dependency-cache.ts +262 -0
- package/src/deps/dependency-dag.ts +128 -0
- package/src/deps/dependency-package-discovery.ts +85 -0
- package/src/deps/dependency-pipeline.ts +483 -0
- package/src/deps/dependency-projection.ts +211 -0
- package/src/deps/dependency-resolver.ts +154 -0
- package/src/deps/workspace-dependencies.ts +114 -0
- package/src/detectors/capability-query.ts +145 -0
- package/src/detectors/confidence.ts +52 -0
- package/src/detectors/d1-db-op-in-loop.ts +457 -0
- package/src/detectors/d10-self-modifying-loop.ts +114 -0
- package/src/detectors/d11-modify-without-get.ts +129 -0
- package/src/detectors/d12-dead-integration-event.ts +81 -0
- package/src/detectors/d13-cross-app-internal-call.ts +105 -0
- package/src/detectors/d14-dead-routine.ts +151 -0
- package/src/detectors/d16-obsolete-routine-call.ts +94 -0
- package/src/detectors/d17-min-version-drift.ts +157 -0
- package/src/detectors/d18-constant-filter-in-loop.ts +151 -0
- package/src/detectors/d19-unused-parameter.ts +116 -0
- package/src/detectors/d2-event-fanout-in-loop.ts +240 -0
- package/src/detectors/d20-unreachable-after-exit.ts +92 -0
- package/src/detectors/d21-read-without-load.ts +128 -0
- package/src/detectors/d22-flowfield-without-calcfields.ts +168 -0
- package/src/detectors/d29-subscriber-modify-on-event-record.ts +163 -0
- package/src/detectors/d3-load-state.ts +72 -0
- package/src/detectors/d3-missing-setloadfields.ts +234 -0
- package/src/detectors/d32-constant-boolean-parameter.ts +185 -0
- package/src/detectors/d33-unfiltered-bulk-write.ts +173 -0
- package/src/detectors/d34-commit-in-loop.ts +206 -0
- package/src/detectors/d35-commit-in-event-subscriber.ts +138 -0
- package/src/detectors/d36-late-setloadfields.ts +162 -0
- package/src/detectors/d37-validate-without-persist.ts +271 -0
- package/src/detectors/d38-subscriber-to-obsolete-event.ts +140 -0
- package/src/detectors/d39-record-left-dirty-across-chain.ts +165 -0
- package/src/detectors/d4-repeated-lookup-in-loop.ts +128 -0
- package/src/detectors/d40-transitive-load-missing.ts +217 -0
- package/src/detectors/d41-transitive-filter-loss.ts +200 -0
- package/src/detectors/d42-cross-call-wrong-setloadfields.ts +243 -0
- package/src/detectors/d43-event-ishandled-skip.ts +257 -0
- package/src/detectors/d44-event-multi-subscriber-overlap.ts +223 -0
- package/src/detectors/d45-event-transitive-table-exposure.ts +159 -0
- package/src/detectors/d5-set-based-opportunity.ts +162 -0
- package/src/detectors/d7-recursive-event-expansion.ts +151 -0
- package/src/detectors/d8-commit-in-transaction.ts +132 -0
- package/src/detectors/d9-transaction-span-summary.ts +107 -0
- package/src/detectors/detector-context.ts +121 -0
- package/src/detectors/finding-grouping.ts +61 -0
- package/src/detectors/path-merge.ts +174 -0
- package/src/detectors/registry.ts +176 -0
- package/src/detectors/table-display.ts +42 -0
- package/src/diff/diff-abi.ts +195 -0
- package/src/diff/diff-capabilities.ts +179 -0
- package/src/diff/diff-engine.ts +146 -0
- package/src/diff/diff-events.ts +323 -0
- package/src/diff/diff-identity.ts +73 -0
- package/src/diff/diff-indexes.ts +199 -0
- package/src/diff/diff-permissions.ts +260 -0
- package/src/diff/diff-policy.ts +101 -0
- package/src/diff/diff-preflight.ts +66 -0
- package/src/diff/diff-renames.ts +104 -0
- package/src/diff/diff-schema.ts +232 -0
- package/src/diff/format-diff.ts +148 -0
- package/src/engine/attribute-parser.ts +50 -0
- package/src/engine/capability-cone.ts +531 -0
- package/src/engine/combined-graph.ts +357 -0
- package/src/engine/control-flow-walker.ts +1317 -0
- package/src/engine/dispatch-sites.ts +199 -0
- package/src/engine/effect-lattice.ts +81 -0
- package/src/engine/entry-points.ts +57 -0
- package/src/engine/event-flow.ts +524 -0
- package/src/engine/event-relay.ts +92 -0
- package/src/engine/op-classification.ts +92 -0
- package/src/engine/path-walker.ts +189 -0
- package/src/engine/reverse-call-graph.ts +23 -0
- package/src/engine/root-classifier-overlay.ts +194 -0
- package/src/engine/root-classifier.ts +135 -0
- package/src/engine/scc.ts +110 -0
- package/src/engine/source-anchor.ts +25 -0
- package/src/engine/summary-context.ts +104 -0
- package/src/engine/summary-engine.ts +296 -0
- package/src/engine/summary-runner.ts +560 -0
- package/src/engine/transaction-spans.ts +112 -0
- package/src/engine/uncertainty-util.ts +54 -0
- package/src/hash.ts +31 -0
- package/src/index/attribute-from-node.ts +141 -0
- package/src/index/callee-from-node.ts +181 -0
- package/src/index/capability/background.ts +90 -0
- package/src/index/capability/commit.ts +44 -0
- package/src/index/capability/dispatch.ts +164 -0
- package/src/index/capability/events.ts +65 -0
- package/src/index/capability/extractor.ts +124 -0
- package/src/index/capability/file-blob.ts +137 -0
- package/src/index/capability/http.ts +159 -0
- package/src/index/capability/hyperlink.ts +60 -0
- package/src/index/capability/isolated-storage.ts +179 -0
- package/src/index/capability/table.ts +113 -0
- package/src/index/capability/telemetry.ts +84 -0
- package/src/index/capability/ui.ts +55 -0
- package/src/index/capability/value-source.ts +202 -0
- package/src/index/expression-from-node.ts +117 -0
- package/src/index/indexer.ts +102 -0
- package/src/index/intraprocedural-body.ts +1467 -0
- package/src/index/intraprocedural-ops.ts +253 -0
- package/src/index/intraprocedural-refs.ts +188 -0
- package/src/index/object-indexer.ts +279 -0
- package/src/index/routine-indexer.ts +282 -0
- package/src/index/routine-signature.ts +46 -0
- package/src/index/variable-indexer.ts +134 -0
- package/src/index/variable-initializer-extractor.ts +155 -0
- package/src/index/variable-type-normalizer.ts +83 -0
- package/src/index.ts +267 -0
- package/src/mcp/server.ts +72 -0
- package/src/mcp/session.ts +49 -0
- package/src/mcp/tools/explain-path.ts +75 -0
- package/src/mcp/tools/get-analysis-health.ts +62 -0
- package/src/mcp/tools/get-finding.ts +47 -0
- package/src/mcp/tools/get-routine-summary.ts +126 -0
- package/src/mcp/tools/list-findings.ts +85 -0
- package/src/mcp/tools/list-hotspots.ts +78 -0
- package/src/mcp/tools/list-rollups.ts +103 -0
- package/src/mcp/tools/validators.ts +25 -0
- package/src/model/attributes.ts +120 -0
- package/src/model/callee.ts +45 -0
- package/src/model/capability.ts +187 -0
- package/src/model/coverage.ts +85 -0
- package/src/model/entities.ts +628 -0
- package/src/model/expression.ts +98 -0
- package/src/model/finding.ts +110 -0
- package/src/model/graph-edge.ts +93 -0
- package/src/model/graph.ts +62 -0
- package/src/model/identity.ts +81 -0
- package/src/model/ids.ts +90 -0
- package/src/model/index.ts +13 -0
- package/src/model/model.ts +51 -0
- package/src/model/permission.ts +76 -0
- package/src/model/root-classification.ts +116 -0
- package/src/model/stable-identity.ts +102 -0
- package/src/model/summary.ts +96 -0
- package/src/parser/ast.ts +82 -0
- package/src/parser/native/ffi.ts +145 -0
- package/src/parser/native/parse-index-pool.ts +148 -0
- package/src/parser/native/parse-index-worker.ts +94 -0
- package/src/parser/native/wrapper.ts +353 -0
- package/src/parser/parser-init.ts +43 -0
- package/src/perf/profiler.ts +66 -0
- package/src/policy/policy-default.yaml +83 -0
- package/src/policy/policy-engine.ts +339 -0
- package/src/policy/policy-loader.ts +257 -0
- package/src/policy/policy-schema.json +379 -0
- package/src/policy/policy-types.ts +81 -0
- package/src/policy/predicate-compiler.ts +151 -0
- package/src/policy/predicate-evaluator.ts +267 -0
- package/src/policy/predicate-fields.ts +439 -0
- package/src/projection/actionable-anchor.ts +48 -0
- package/src/projection/finding-filters.ts +44 -0
- package/src/projection/finding-fingerprint.ts +54 -0
- package/src/projection/finding-groups.ts +41 -0
- package/src/projection/finding-summary.ts +110 -0
- package/src/projection/rollup-findings.ts +105 -0
- package/src/providers/discover.ts +88 -0
- package/src/providers/external.ts +46 -0
- package/src/providers/types.ts +36 -0
- package/src/providers/workspace.ts +117 -0
- package/src/resolve/call-resolver.ts +117 -0
- package/src/resolve/coverage.ts +61 -0
- package/src/resolve/event-graph.ts +166 -0
- package/src/resolve/implicit-edges.ts +53 -0
- package/src/resolve/record-types.ts +36 -0
- package/src/resolve/resolver.ts +23 -0
- package/src/resolve/semantic-graph.ts +29 -0
- package/src/resolve/symbol-table.ts +69 -0
- package/src/snapshot/app-snapshot.ts +74 -0
- package/src/snapshot/compose.ts +100 -0
- package/src/snapshot/derive/callsite-evidence.ts +76 -0
- package/src/snapshot/derive/capability-facts.ts +70 -0
- package/src/snapshot/derive/contracts.ts +131 -0
- package/src/snapshot/derive/coverage.ts +35 -0
- package/src/snapshot/derive/event-declarations.ts +140 -0
- package/src/snapshot/derive/identity-table.ts +58 -0
- package/src/snapshot/derive/inputs.ts +91 -0
- package/src/snapshot/derive/operation-evidence.ts +70 -0
- package/src/snapshot/derive/permissions.ts +186 -0
- package/src/snapshot/derive/root-classifications.ts +56 -0
- package/src/snapshot/derive/schema.ts +130 -0
- package/src/snapshot/derive/typed-edges.ts +60 -0
- package/src/snapshot/derive/workspace-fingerprint.ts +19 -0
- package/src/snapshot/deserialize.ts +40 -0
- package/src/snapshot/serialize-cbor-gz.ts +12 -0
- package/src/snapshot/serialize-cbor.ts +19 -0
- package/src/snapshot/serialize-json.ts +22 -0
- package/src/snapshot/shard.ts +134 -0
- package/src/snapshot/types.ts +181 -0
- package/src/symbols/app-manifest.ts +96 -0
- package/src/symbols/app-package-zip.ts +50 -0
- package/src/symbols/embedded-source-reader.ts +41 -0
- package/src/symbols/package-hash.ts +81 -0
- package/src/symbols/symbol-reader.ts +101 -0
- package/src/symbols/symbol-reference-parser.ts +378 -0
- package/src/symbols/symbol-reference-reader.ts +27 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
2
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
3
|
+
import { roleOf } from "../model/entities.ts";
|
|
4
|
+
import type { RecordOperation } from "../model/entities.ts";
|
|
5
|
+
import { isStringLikeLiteral } from "../model/expression.ts";
|
|
6
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
7
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
8
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
9
|
+
import { toConfidence } from "./confidence.ts";
|
|
10
|
+
import type { DetectorContext } from "./detector-context.ts";
|
|
11
|
+
|
|
12
|
+
const LOOKUP_OPS: ReadonlySet<string> = new Set(["Get", "FindFirst", "FindLast"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* D4: detect `Get` / `FindFirst` / `FindLast` called 2+ times inside the same loop
|
|
16
|
+
* with the same literal key argument on the same record variable.
|
|
17
|
+
*
|
|
18
|
+
* v1 only matches string-literal arguments (`'...'` or `"..."`), which is the
|
|
19
|
+
* conservative correct case — the key is known at compile time, so it can trivially
|
|
20
|
+
* be hoisted outside the loop. Future versions can extend this to dataflow over
|
|
21
|
+
* key expressions.
|
|
22
|
+
*/
|
|
23
|
+
export function detectD4(
|
|
24
|
+
model: SemanticModel,
|
|
25
|
+
_graph: CombinedGraph,
|
|
26
|
+
_ctx: DetectorContext,
|
|
27
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
28
|
+
const findings: Finding[] = [];
|
|
29
|
+
let candidatesConsidered = 0;
|
|
30
|
+
let skippedOther = 0;
|
|
31
|
+
|
|
32
|
+
for (const routine of model.routines) {
|
|
33
|
+
if (roleOf(routine) !== "primary") continue;
|
|
34
|
+
if (!routine.bodyAvailable) continue;
|
|
35
|
+
if (routine.parseIncomplete) continue;
|
|
36
|
+
candidatesConsidered++;
|
|
37
|
+
let emittedForRoutine = 0;
|
|
38
|
+
|
|
39
|
+
for (const loop of routine.features.loops) {
|
|
40
|
+
// Collect all lookup ops that sit inside this loop and have a literal key argument.
|
|
41
|
+
const candidates: { op: RecordOperation; key: string }[] = [];
|
|
42
|
+
for (const op of routine.features.recordOperations) {
|
|
43
|
+
if (!LOOKUP_OPS.has(op.op)) continue;
|
|
44
|
+
if (!op.loopStack.includes(loop.id)) continue;
|
|
45
|
+
// Only flag string-like literal keys (`'VALUE'` / `"VALUE"`); the structured
|
|
46
|
+
// classifier distinguishes them from identifiers / expressions without any
|
|
47
|
+
// text shredding. Dedup key uses the unquoted `.value` so `'X'` and `"X"`
|
|
48
|
+
// (same content, different quoting) group together.
|
|
49
|
+
const keyInfo = op.fieldArgumentInfos?.[0];
|
|
50
|
+
if (keyInfo === undefined) continue;
|
|
51
|
+
if (!isStringLikeLiteral(keyInfo)) continue;
|
|
52
|
+
candidates.push({ op, key: keyInfo.value ?? keyInfo.text });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (candidates.length < 2) continue;
|
|
56
|
+
|
|
57
|
+
// Group by (recordVariableName.toLowerCase, literal key) — duplicates per group → finding.
|
|
58
|
+
const groups = new Map<string, RecordOperation[]>();
|
|
59
|
+
for (const { op, key } of candidates) {
|
|
60
|
+
const groupKey = `${op.recordVariableName.toLowerCase()}|${key}`;
|
|
61
|
+
const list = groups.get(groupKey);
|
|
62
|
+
if (list) list.push(op);
|
|
63
|
+
else groups.set(groupKey, [op]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const ops of groups.values()) {
|
|
67
|
+
if (ops.length < 2) continue;
|
|
68
|
+
const first = ops[0];
|
|
69
|
+
if (!first) continue;
|
|
70
|
+
|
|
71
|
+
const path: EvidenceStep[] = [
|
|
72
|
+
{
|
|
73
|
+
routineId: routine.id,
|
|
74
|
+
loopId: loop.id,
|
|
75
|
+
sourceAnchor: loop.sourceAnchor,
|
|
76
|
+
note: `${loop.type} loop`,
|
|
77
|
+
},
|
|
78
|
+
...ops.map(
|
|
79
|
+
(o): EvidenceStep => ({
|
|
80
|
+
routineId: routine.id,
|
|
81
|
+
operationId: o.id,
|
|
82
|
+
sourceAnchor: o.sourceAnchor,
|
|
83
|
+
note: `${o.op} on ${o.recordVariableName} with literal key`,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const finding: Finding = {
|
|
89
|
+
id: `d4/${routine.id}/${loop.id}/${first.recordVariableName.toLowerCase()}`,
|
|
90
|
+
rootCauseKey: `d4/${routine.id}/${loop.id}/${first.recordVariableName.toLowerCase()}`,
|
|
91
|
+
detector: "d4-repeated-lookup-in-loop",
|
|
92
|
+
title: "Repeated identical lookup inside a loop",
|
|
93
|
+
rootCause: `${routine.name} calls ${first.op} on ${first.recordVariableName} ${ops.length} times inside a loop with the same literal key — cache the result once before the loop.`,
|
|
94
|
+
severity: "medium",
|
|
95
|
+
confidence: toConfidence([], "likely"),
|
|
96
|
+
primaryLocation: first.sourceAnchor,
|
|
97
|
+
evidencePath: path,
|
|
98
|
+
affectedObjects: [routine.objectId],
|
|
99
|
+
affectedTables: first.tableId !== undefined ? [first.tableId] : [],
|
|
100
|
+
fixOptions: [
|
|
101
|
+
{
|
|
102
|
+
description:
|
|
103
|
+
"Move the lookup out of the loop into a local variable, then read fields from that variable inside the loop.",
|
|
104
|
+
safety: "high",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
provenance: [{ source: "tree-sitter" }],
|
|
108
|
+
};
|
|
109
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
110
|
+
findings.push(finding);
|
|
111
|
+
emittedForRoutine++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (emittedForRoutine === 0) skippedOther++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
findings: sorted,
|
|
121
|
+
stats: {
|
|
122
|
+
detector: "d4-repeated-lookup-in-loop",
|
|
123
|
+
candidatesConsidered,
|
|
124
|
+
findingsEmitted: sorted.length,
|
|
125
|
+
skipped: { other: skippedOther > 0 ? skippedOther : undefined },
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
2
|
+
import { recordFlowRoleOf } from "../engine/op-classification.ts";
|
|
3
|
+
import { beforeAnchor } from "../engine/source-anchor.ts";
|
|
4
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
5
|
+
import { type RecordOpType, type RecordOperation, roleOf } from "../model/entities.ts";
|
|
6
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
7
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
8
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
9
|
+
import { toConfidence } from "./confidence.ts";
|
|
10
|
+
import type { DetectorContext } from "./detector-context.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* D40 — transitive load missing.
|
|
14
|
+
*
|
|
15
|
+
* For each resolved call edge where the callee requires its parameter to be
|
|
16
|
+
* loaded at entry (callee.parameterRoles[Q].requiresLoadedAtEntry === "yes"),
|
|
17
|
+
* verify the caller has loaded the forwarded record before the callsite.
|
|
18
|
+
* Otherwise emit a finding at the caller's callsite.
|
|
19
|
+
*
|
|
20
|
+
* Severity: `medium`, escalates to `high` when the callee mutates the unloaded
|
|
21
|
+
* record (callee.mutatesBeforeLoad === "yes" — strictly worse than a read).
|
|
22
|
+
*
|
|
23
|
+
* Predicate:
|
|
24
|
+
* - resolved callsite + binding;
|
|
25
|
+
* - source is NOT an implicit-rec (`Rec`/`xRec` in triggers/event subscribers are
|
|
26
|
+
* loaded by the AL runtime before the trigger fires — flagging them is a
|
|
27
|
+
* structural false positive);
|
|
28
|
+
* - source-tempState is not `known/true` (temporary records have no DB load concept);
|
|
29
|
+
* - callee.parameterRoles[Q].requiresLoadedAtEntry === "yes";
|
|
30
|
+
* - caller has not loaded the forwarded record on the path to the callsite
|
|
31
|
+
* (intra-routine source-ordered check using `isLoadingOp` — see below).
|
|
32
|
+
*
|
|
33
|
+
* Skipped (counters):
|
|
34
|
+
* - `unresolved` — binding.bindingResolution !== "resolved", or no resolved edge.
|
|
35
|
+
* - `implicitRec` — binding.sourceKind === "implicit-rec".
|
|
36
|
+
* - `tempRecord` — binding.sourceTempState is known-true.
|
|
37
|
+
* - `calleeUnknown` — callee summary missing or no role for the parameter.
|
|
38
|
+
* - `callerLoaded` — caller has a prior loading op for the same source record.
|
|
39
|
+
*
|
|
40
|
+
* NOTE — opt-in pending Phase 6. D40 is intentionally NOT in the default detector
|
|
41
|
+
* registry while the Phase 4 walker remains straight-line-only. The walker bails to
|
|
42
|
+
* `requiresLoadedAtEntry = "unknown"` on any branching control flow, and D40's
|
|
43
|
+
* intra-caller load check doesn't see records loaded inside loops (FindSet/repeat/
|
|
44
|
+
* Next pattern). On Continia DC/Cloud the default-on shape produced ~1186 medium
|
|
45
|
+
* findings dominated by the loop-loaded false-positive class. Phase 6's full
|
|
46
|
+
* statement-tree walker closes that class; D40 returns to the default registry then.
|
|
47
|
+
* Until then it is usable via `--detector d40-transitive-load-missing`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True iff a record op acts as "load" for the parameter — after running it, the record
|
|
52
|
+
* variable is in a well-defined loaded/initialised state. Mirrors the Phase 4 walker's
|
|
53
|
+
* `loaded = true` triggers (loadsFromDb / initialises / copiesInto). The previous
|
|
54
|
+
* hand-maintained LOAD_OPS set is replaced by this single source of truth via
|
|
55
|
+
* `recordFlowRoleOf`, so future RecordOpType additions update one place.
|
|
56
|
+
*/
|
|
57
|
+
function isLoadingOp(op: RecordOpType): boolean {
|
|
58
|
+
const role = recordFlowRoleOf(op);
|
|
59
|
+
return role === "loadsFromDb" || role === "initialises" || role === "copiesInto";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function detectD40(
|
|
63
|
+
model: SemanticModel,
|
|
64
|
+
_graph: CombinedGraph,
|
|
65
|
+
ctx: DetectorContext,
|
|
66
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
67
|
+
const findings: Finding[] = [];
|
|
68
|
+
const { routineById, resolvedCallEdgeByCallsite } = ctx;
|
|
69
|
+
|
|
70
|
+
let candidatesConsidered = 0;
|
|
71
|
+
let skippedUnresolved = 0;
|
|
72
|
+
let skippedImplicitRec = 0;
|
|
73
|
+
let skippedTempRecord = 0;
|
|
74
|
+
let skippedCallerLoaded = 0;
|
|
75
|
+
let skippedCalleeUnknown = 0;
|
|
76
|
+
|
|
77
|
+
for (const routine of model.routines) {
|
|
78
|
+
if (roleOf(routine) !== "primary") continue;
|
|
79
|
+
if (!routine.bodyAvailable) continue;
|
|
80
|
+
if (routine.parseIncomplete) continue;
|
|
81
|
+
|
|
82
|
+
// I2: precompute load-op buckets per source variable once per routine.
|
|
83
|
+
// Key = lowercase recordVariableName. Each bucket also retains the op's
|
|
84
|
+
// recordVariableId (when present) so we can prefer id-matching at the binding
|
|
85
|
+
// site (I4).
|
|
86
|
+
const loadsBySourceLc = new Map<string, RecordOperation[]>();
|
|
87
|
+
for (const op of routine.features.recordOperations) {
|
|
88
|
+
if (!isLoadingOp(op.op)) continue;
|
|
89
|
+
const key = op.recordVariableName.toLowerCase();
|
|
90
|
+
let bucket = loadsBySourceLc.get(key);
|
|
91
|
+
if (bucket === undefined) {
|
|
92
|
+
bucket = [];
|
|
93
|
+
loadsBySourceLc.set(key, bucket);
|
|
94
|
+
}
|
|
95
|
+
bucket.push(op);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const cs of routine.features.callSites) {
|
|
99
|
+
// I1: O(1) edge lookup via the new DetectorContext index.
|
|
100
|
+
const edge = resolvedCallEdgeByCallsite.get(cs.id);
|
|
101
|
+
if (edge?.to === undefined) {
|
|
102
|
+
skippedUnresolved++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const callee = routineById.get(edge.to);
|
|
106
|
+
if (callee === undefined) continue;
|
|
107
|
+
|
|
108
|
+
for (const binding of cs.argumentBindings) {
|
|
109
|
+
// C2(a): implicit-rec narrowing — checked BEFORE bindingResolution so that
|
|
110
|
+
// triggers passing `Rec` (where the indexer classifies the arg as
|
|
111
|
+
// `non-record-arg` because there is no local recVar named `Rec` even though
|
|
112
|
+
// `sourceKind` is correctly `"implicit-rec"`) are still recognised and skipped.
|
|
113
|
+
// `Rec` / `xRec` inside a trigger or event subscriber are loaded by the AL
|
|
114
|
+
// runtime before the trigger fires — there is no caller in source code that
|
|
115
|
+
// could "Get them". Flagging is a structural false positive (~87 cases on
|
|
116
|
+
// DC/Cloud at the pre-narrowing default — ~7% of total).
|
|
117
|
+
if (binding.sourceKind === "implicit-rec") {
|
|
118
|
+
skippedImplicitRec++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (binding.bindingResolution !== "resolved") {
|
|
122
|
+
skippedUnresolved++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (binding.sourceTempState?.kind === "known" && binding.sourceTempState.value === true) {
|
|
126
|
+
skippedTempRecord++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const calleeRole = callee.summary?.parameterRoles.find(
|
|
130
|
+
(r) => r.parameterIndex === binding.parameterIndex,
|
|
131
|
+
);
|
|
132
|
+
if (calleeRole === undefined) {
|
|
133
|
+
skippedCalleeUnknown++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (calleeRole.requiresLoadedAtEntry !== "yes") continue;
|
|
137
|
+
candidatesConsidered++;
|
|
138
|
+
|
|
139
|
+
const sourceNameLc = binding.sourceVariableName;
|
|
140
|
+
if (sourceNameLc === undefined) continue;
|
|
141
|
+
const bucket = loadsBySourceLc.get(sourceNameLc) ?? [];
|
|
142
|
+
// I4: prefer id-match when both sides have an id; fall back to the
|
|
143
|
+
// name-keyed bucket (AL forbids shadowing within a single procedure, so
|
|
144
|
+
// names disambiguate uniquely in practice).
|
|
145
|
+
const sourceId = binding.sourceRecordVariableId;
|
|
146
|
+
const loadedBefore = bucket.some((op) => {
|
|
147
|
+
if (!beforeAnchor(op.sourceAnchor, cs.sourceAnchor)) return false;
|
|
148
|
+
if (sourceId !== undefined && op.recordVariableId !== undefined) {
|
|
149
|
+
return op.recordVariableId === sourceId;
|
|
150
|
+
}
|
|
151
|
+
return true; // name-match already established by bucket lookup
|
|
152
|
+
});
|
|
153
|
+
if (loadedBefore) {
|
|
154
|
+
skippedCallerLoaded++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const severity: Finding["severity"] =
|
|
159
|
+
calleeRole.mutatesBeforeLoad === "yes" ? "high" : "medium";
|
|
160
|
+
|
|
161
|
+
const path: EvidenceStep[] = [
|
|
162
|
+
{
|
|
163
|
+
routineId: routine.id,
|
|
164
|
+
callsiteId: cs.id,
|
|
165
|
+
sourceAnchor: binding.argumentAnchor,
|
|
166
|
+
note: `forwards ${binding.sourceVariableName} to ${callee.name} (param[${binding.parameterIndex}])`,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
routineId: callee.id,
|
|
170
|
+
sourceAnchor: callee.sourceAnchor,
|
|
171
|
+
note: `${callee.name} ${calleeRole.mutatesBeforeLoad === "yes" ? "mutates" : "reads"} this record before loading it`,
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const finding: Finding = {
|
|
176
|
+
id: `d40/${routine.id}/${cs.id}/${binding.parameterIndex}`,
|
|
177
|
+
rootCauseKey: `d40/${routine.id}/${cs.id}/${binding.parameterIndex}`,
|
|
178
|
+
detector: "d40-transitive-load-missing",
|
|
179
|
+
title: `Forwarded record not loaded before ${calleeRole.mutatesBeforeLoad === "yes" ? "mutating" : "reading"} helper`,
|
|
180
|
+
rootCause: `${routine.name} forwards ${binding.sourceVariableName} to ${callee.name}, which ${calleeRole.mutatesBeforeLoad === "yes" ? "mutates" : "reads"} the record without loading it — the caller must Get/Find the record before the call.`,
|
|
181
|
+
severity,
|
|
182
|
+
confidence: toConfidence([], "likely"),
|
|
183
|
+
primaryLocation: binding.argumentAnchor,
|
|
184
|
+
evidencePath: path,
|
|
185
|
+
affectedObjects: [routine.objectId, callee.objectId].sort(),
|
|
186
|
+
affectedTables: [],
|
|
187
|
+
fixOptions: [
|
|
188
|
+
{
|
|
189
|
+
description: `Load ${binding.sourceVariableName} with Get / FindFirst before forwarding to ${callee.name}, or have ${callee.name} load its parameter internally.`,
|
|
190
|
+
safety: "high",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
provenance: [{ source: "tree-sitter" }],
|
|
194
|
+
};
|
|
195
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
196
|
+
findings.push(finding);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
202
|
+
return {
|
|
203
|
+
findings: sorted,
|
|
204
|
+
stats: {
|
|
205
|
+
detector: "d40-transitive-load-missing",
|
|
206
|
+
candidatesConsidered,
|
|
207
|
+
findingsEmitted: sorted.length,
|
|
208
|
+
skipped: {
|
|
209
|
+
...(skippedUnresolved > 0 ? { unresolved: skippedUnresolved } : {}),
|
|
210
|
+
...(skippedImplicitRec > 0 ? { implicitRec: skippedImplicitRec } : {}),
|
|
211
|
+
...(skippedTempRecord > 0 ? { tempRecord: skippedTempRecord } : {}),
|
|
212
|
+
...(skippedCallerLoaded > 0 ? { callerLoaded: skippedCallerLoaded } : {}),
|
|
213
|
+
...(skippedCalleeUnknown > 0 ? { calleeUnknown: skippedCalleeUnknown } : {}),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
2
|
+
import { beforeAnchor } from "../engine/source-anchor.ts";
|
|
3
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
4
|
+
import { roleOf } from "../model/entities.ts";
|
|
5
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
6
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
7
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
8
|
+
import { toConfidence } from "./confidence.ts";
|
|
9
|
+
import type { DetectorContext } from "./detector-context.ts";
|
|
10
|
+
|
|
11
|
+
const FILTER_SET_OPS: ReadonlySet<string> = new Set(["SetRange", "SetFilter"]);
|
|
12
|
+
const FILTER_SENSITIVE_OPS: ReadonlySet<string> = new Set([
|
|
13
|
+
"FindFirst",
|
|
14
|
+
"FindLast",
|
|
15
|
+
"FindSet",
|
|
16
|
+
"Find",
|
|
17
|
+
"Next",
|
|
18
|
+
"CalcSums",
|
|
19
|
+
"DeleteAll",
|
|
20
|
+
"ModifyAll",
|
|
21
|
+
"Count",
|
|
22
|
+
"IsEmpty",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* D41 — transitive filter loss.
|
|
27
|
+
*
|
|
28
|
+
* Predicate (all four must hold):
|
|
29
|
+
* 1. Caller called SetRange / SetFilter on R before a callsite that forwards R
|
|
30
|
+
* by-var to a callee;
|
|
31
|
+
* 2. Callee's parameterRoles[Q].resetsFiltersOnParam === "yes" (the helper calls
|
|
32
|
+
* Reset on the forwarded record);
|
|
33
|
+
* 3. Caller subsequently performs a filter-sensitive op on R (FindFirst, FindLast,
|
|
34
|
+
* FindSet, Find, Next, CalcSums, DeleteAll, ModifyAll, Count, IsEmpty) AFTER
|
|
35
|
+
* the callsite;
|
|
36
|
+
* 4. Caller did NOT re-filter R between the callsite and the sensitive op.
|
|
37
|
+
*
|
|
38
|
+
* Without clause (3)+(4) the pattern is unobservable or intentional (the helper
|
|
39
|
+
* was called for the explicit purpose of resetting filters). This requirement is
|
|
40
|
+
* what makes D41 actionable rather than noisy.
|
|
41
|
+
*
|
|
42
|
+
* Severity: high (silent wrong-set-size). Confidence: likely.
|
|
43
|
+
* Anchor: caller's argumentAnchor (the argument forwarding the record).
|
|
44
|
+
*
|
|
45
|
+
* Skip counters:
|
|
46
|
+
* - `noPriorFilter` — caller did not filter R before the callsite
|
|
47
|
+
* - `noPostUse` — no filter-sensitive op on R after the callsite
|
|
48
|
+
* - `reFiltered` — caller re-filters R between the callsite and the sensitive op
|
|
49
|
+
*
|
|
50
|
+
* Control-flow-blindness carry-forward: the prior-filter check and the re-filter
|
|
51
|
+
* check are both source-order linear (same as D39's persist check). A SetRange/
|
|
52
|
+
* SetFilter that lives in a mutually-exclusive branch from the callsite can create
|
|
53
|
+
* false positives; a re-filter in a mutually-exclusive branch can create false
|
|
54
|
+
* negatives. These are the same FN/FP classes D39 documents and defers to a future
|
|
55
|
+
* walker-aware pass.
|
|
56
|
+
*/
|
|
57
|
+
export function detectD41(
|
|
58
|
+
model: SemanticModel,
|
|
59
|
+
_graph: CombinedGraph,
|
|
60
|
+
ctx: DetectorContext,
|
|
61
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
62
|
+
const findings: Finding[] = [];
|
|
63
|
+
const { routineById } = ctx;
|
|
64
|
+
|
|
65
|
+
let candidatesConsidered = 0;
|
|
66
|
+
let skippedNoPriorFilter = 0;
|
|
67
|
+
let skippedNoPostUse = 0;
|
|
68
|
+
let skippedReFiltered = 0;
|
|
69
|
+
|
|
70
|
+
for (const routine of model.routines) {
|
|
71
|
+
if (roleOf(routine) !== "primary") continue;
|
|
72
|
+
if (!routine.bodyAvailable) continue;
|
|
73
|
+
if (routine.parseIncomplete) continue;
|
|
74
|
+
|
|
75
|
+
for (const cs of routine.features.callSites) {
|
|
76
|
+
// O(1) resolved-edge lookup via the DetectorContext index (added in Phase 4 / I1).
|
|
77
|
+
const edge = ctx.resolvedCallEdgeByCallsite.get(cs.id);
|
|
78
|
+
if (edge?.to === undefined) continue;
|
|
79
|
+
const callee = routineById.get(edge.to);
|
|
80
|
+
if (callee === undefined) continue;
|
|
81
|
+
|
|
82
|
+
for (const binding of cs.argumentBindings) {
|
|
83
|
+
if (binding.bindingResolution !== "resolved") continue;
|
|
84
|
+
if (!binding.calleeParameterIsVar) continue;
|
|
85
|
+
|
|
86
|
+
const calleeRole = callee.summary?.parameterRoles.find(
|
|
87
|
+
(r) => r.parameterIndex === binding.parameterIndex,
|
|
88
|
+
);
|
|
89
|
+
if (calleeRole?.resetsFiltersOnParam !== "yes") continue;
|
|
90
|
+
|
|
91
|
+
const sourceNameLc = binding.sourceVariableName;
|
|
92
|
+
if (sourceNameLc === undefined) continue;
|
|
93
|
+
// Count every binding that targets a reset-helper callee — the meaningful
|
|
94
|
+
// candidate set at the structural gate. Matches D39's convention so
|
|
95
|
+
// cross-detector stats comparisons are meaningful.
|
|
96
|
+
candidatesConsidered++;
|
|
97
|
+
|
|
98
|
+
// Precompute all ops on this variable once for the three predicate checks.
|
|
99
|
+
const opsOnVar = routine.features.recordOperations.filter(
|
|
100
|
+
(op) => op.recordVariableName.toLowerCase() === sourceNameLc,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// (1) Caller filtered before the call?
|
|
104
|
+
const priorFilters = opsOnVar.filter(
|
|
105
|
+
(op) => FILTER_SET_OPS.has(op.op) && beforeAnchor(op.sourceAnchor, cs.sourceAnchor),
|
|
106
|
+
);
|
|
107
|
+
if (priorFilters.length === 0) {
|
|
108
|
+
skippedNoPriorFilter++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// (3) Any filter-sensitive op AFTER the callsite?
|
|
113
|
+
const postSensitive = opsOnVar.filter(
|
|
114
|
+
(op) => FILTER_SENSITIVE_OPS.has(op.op) && beforeAnchor(cs.sourceAnchor, op.sourceAnchor),
|
|
115
|
+
);
|
|
116
|
+
if (postSensitive.length === 0) {
|
|
117
|
+
skippedNoPostUse++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// (4) Re-filter between callsite and the sensitive op?
|
|
122
|
+
// biome-ignore lint/style/noNonNullAssertion: guarded by postSensitive.length === 0 check above
|
|
123
|
+
const firstSensitive = postSensitive[0]!;
|
|
124
|
+
const reFiltered = opsOnVar.some(
|
|
125
|
+
(op) =>
|
|
126
|
+
FILTER_SET_OPS.has(op.op) &&
|
|
127
|
+
beforeAnchor(cs.sourceAnchor, op.sourceAnchor) &&
|
|
128
|
+
beforeAnchor(op.sourceAnchor, firstSensitive.sourceAnchor),
|
|
129
|
+
);
|
|
130
|
+
if (reFiltered) {
|
|
131
|
+
skippedReFiltered++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// priorFilters[0] is guaranteed to exist (guarded by priorFilters.length === 0 check above).
|
|
136
|
+
const firstPriorFilter = priorFilters[0];
|
|
137
|
+
if (firstPriorFilter === undefined) continue; // unreachable, satisfies the linter
|
|
138
|
+
|
|
139
|
+
const path: EvidenceStep[] = [
|
|
140
|
+
{
|
|
141
|
+
routineId: routine.id,
|
|
142
|
+
operationId: firstPriorFilter.id,
|
|
143
|
+
sourceAnchor: firstPriorFilter.sourceAnchor,
|
|
144
|
+
note: `${firstPriorFilter.op} on ${sourceNameLc}`,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
routineId: routine.id,
|
|
148
|
+
callsiteId: cs.id,
|
|
149
|
+
sourceAnchor: binding.argumentAnchor,
|
|
150
|
+
note: `forwards ${sourceNameLc} to ${callee.name}, which calls Reset`,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
routineId: routine.id,
|
|
154
|
+
operationId: firstSensitive.id,
|
|
155
|
+
sourceAnchor: firstSensitive.sourceAnchor,
|
|
156
|
+
note: `${firstSensitive.op} on ${sourceNameLc} — operates on the now-unfiltered set`,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const finding: Finding = {
|
|
161
|
+
id: `d41/${routine.id}/${cs.id}/${binding.parameterIndex}`,
|
|
162
|
+
rootCauseKey: `d41/${routine.id}/${cs.id}/${binding.parameterIndex}`,
|
|
163
|
+
detector: "d41-transitive-filter-loss",
|
|
164
|
+
title: "Filter silently lost across helper call",
|
|
165
|
+
rootCause: `${routine.name} filters ${sourceNameLc} before calling ${callee.name}, which calls Reset; the subsequent ${firstSensitive.op} operates on the unfiltered set.`,
|
|
166
|
+
severity: "high",
|
|
167
|
+
confidence: toConfidence([], "likely"),
|
|
168
|
+
primaryLocation: binding.argumentAnchor,
|
|
169
|
+
evidencePath: path,
|
|
170
|
+
affectedObjects: [routine.objectId, callee.objectId].sort(),
|
|
171
|
+
affectedTables: [],
|
|
172
|
+
fixOptions: [
|
|
173
|
+
{
|
|
174
|
+
description: `Re-apply the SetRange/SetFilter on ${sourceNameLc} after the call to ${callee.name}, or restructure to avoid the call inside the filtered scope.`,
|
|
175
|
+
safety: "high",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
provenance: [{ source: "tree-sitter" }],
|
|
179
|
+
};
|
|
180
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
181
|
+
findings.push(finding);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
187
|
+
return {
|
|
188
|
+
findings: sorted,
|
|
189
|
+
stats: {
|
|
190
|
+
detector: "d41-transitive-filter-loss",
|
|
191
|
+
candidatesConsidered,
|
|
192
|
+
findingsEmitted: sorted.length,
|
|
193
|
+
skipped: {
|
|
194
|
+
...(skippedNoPriorFilter > 0 ? { noPriorFilter: skippedNoPriorFilter } : {}),
|
|
195
|
+
...(skippedNoPostUse > 0 ? { noPostUse: skippedNoPostUse } : {}),
|
|
196
|
+
...(skippedReFiltered > 0 ? { reFiltered: skippedReFiltered } : {}),
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|