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,457 @@
|
|
|
1
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
2
|
+
import { classifyOp, isDbTouchingClass } from "../engine/op-classification.ts";
|
|
3
|
+
import type { Terminal, WalkPolicy, WalkResult } from "../engine/path-walker.ts";
|
|
4
|
+
import { walkEvidence } from "../engine/path-walker.ts";
|
|
5
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
6
|
+
import { roleOf } from "../model/entities.ts";
|
|
7
|
+
import type { RecordOperation, Routine, Table } from "../model/entities.ts";
|
|
8
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
9
|
+
import type { LoopId, RoutineId, TableId } from "../model/ids.ts";
|
|
10
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
11
|
+
import type { DbEffect } from "../model/summary.ts";
|
|
12
|
+
import { pickActionableAnchor } from "../projection/actionable-anchor.ts";
|
|
13
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
14
|
+
import { touchesDbOf } from "./capability-query.ts";
|
|
15
|
+
import { toConfidence } from "./confidence.ts";
|
|
16
|
+
import type { DetectorContext } from "./detector-context.ts";
|
|
17
|
+
import { mergeByTerminal } from "./path-merge.ts";
|
|
18
|
+
import { describeTable } from "./table-display.ts";
|
|
19
|
+
|
|
20
|
+
// The path-walker's depth/node budget for the interprocedural call-chain walk.
|
|
21
|
+
const BOUNDS = { maxDepth: 20, maxNodes: 500 };
|
|
22
|
+
|
|
23
|
+
const WRITE_OPS = new Set(["Modify", "ModifyAll", "Insert", "Delete", "DeleteAll"]);
|
|
24
|
+
const HEAVY_READ_OPS = new Set(["CalcFields", "CalcSums"]); // FlowField materialisation = high cost
|
|
25
|
+
const RETRIEVAL_OPS = new Set(["FindSet", "FindFirst", "FindLast", "Find", "Get", "Next"]);
|
|
26
|
+
/**
|
|
27
|
+
* Ops that open a recordset cursor BEFORE a `repeat..until` loop. When an in-loop `Next`
|
|
28
|
+
* has the same record-var as one of these earlier ops, the Next IS the cursor advance —
|
|
29
|
+
* not an N+1 antipattern. Without this filter `Next` produced ~28% of D1's findings on
|
|
30
|
+
* real workspaces, all on legitimate FindSet+repeat patterns.
|
|
31
|
+
*/
|
|
32
|
+
const CURSOR_OPENER_OPS = new Set(["FindSet", "FindFirst", "FindLast", "Find"]);
|
|
33
|
+
|
|
34
|
+
interface D1Terminal extends Terminal {
|
|
35
|
+
op: RecordOperation;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* BC "setup singleton" pattern: tables whose name ends in `Setup` are by AL convention
|
|
40
|
+
* single-record config tables (General Ledger Setup, Sales & Receivables Setup, custom
|
|
41
|
+
* `CDO Setup`, etc.). BC caches `<Setup>.Get()` per session, so an in-loop Get on such a
|
|
42
|
+
* table is typically O(1) after the first hit — actionably weak as an N+1 warning.
|
|
43
|
+
*
|
|
44
|
+
* We downgrade these findings to `info` rather than suppressing entirely: the call is
|
|
45
|
+
* still technically a DB op inside a loop, and a strict consumer can opt back in by
|
|
46
|
+
* lowering `--min-severity` (info is below the usual `--min-severity high` threshold).
|
|
47
|
+
*
|
|
48
|
+
* Narrow conditions for the heuristic to apply:
|
|
49
|
+
* - op kind is `Get` (the by-PK lookup that participates in BC's singleton cache);
|
|
50
|
+
* - the rendered table-display name ends in `Setup` (case-insensitive, after stripping
|
|
51
|
+
* the `(type not loaded)` suffix that `describeTable` adds when only the variable's
|
|
52
|
+
* declared type is known).
|
|
53
|
+
*
|
|
54
|
+
* `Find*` ops on the same table do not trigger the heuristic — they imply a multi-record
|
|
55
|
+
* scan and are legitimate D1 signal.
|
|
56
|
+
*/
|
|
57
|
+
function isSetupSingletonGet(
|
|
58
|
+
op: RecordOperation,
|
|
59
|
+
routine: Routine | undefined,
|
|
60
|
+
tableById: Map<TableId, Table>,
|
|
61
|
+
): boolean {
|
|
62
|
+
if (op.op !== "Get") return false;
|
|
63
|
+
const display = describeTable(op, routine, tableById);
|
|
64
|
+
// Strip the `(type not loaded)` suffix so both the resolved-table and type-only paths
|
|
65
|
+
// land on the same naming check. `var <name>` and `unknown table` fall through to false.
|
|
66
|
+
const name = display.replace(/\s*\(type not loaded\)$/i, "").trim();
|
|
67
|
+
if (name === "" || name.startsWith("var ") || name === "unknown table") return false;
|
|
68
|
+
return /\bSetup$/i.test(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The representative loop of a loopStack — the innermost loop the op/callsite sits in.
|
|
73
|
+
* `loopStack` is outermost-first (see test/intraprocedural-ops.test.ts), so the innermost
|
|
74
|
+
* loop is the LAST element. Findings are keyed on this so a deeply nested op reports once.
|
|
75
|
+
*/
|
|
76
|
+
function representativeLoopId(loopStack: LoopId[]): LoopId | undefined {
|
|
77
|
+
return loopStack.at(-1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function severityFor(
|
|
81
|
+
op: RecordOperation,
|
|
82
|
+
effectiveLoopDepth: number,
|
|
83
|
+
isSetupSingleton: boolean,
|
|
84
|
+
): Finding["severity"] {
|
|
85
|
+
if (op.tempState.kind === "known" && op.tempState.value === true) return "info";
|
|
86
|
+
if (isSetupSingleton) return "info";
|
|
87
|
+
let base: Finding["severity"];
|
|
88
|
+
if (WRITE_OPS.has(op.op))
|
|
89
|
+
base = "high"; // write inside loop = always high
|
|
90
|
+
else if (HEAVY_READ_OPS.has(op.op))
|
|
91
|
+
base = "high"; // FlowField materialisation = high
|
|
92
|
+
else if (RETRIEVAL_OPS.has(op.op))
|
|
93
|
+
base = "medium"; // pure retrieval = medium
|
|
94
|
+
else if (classifyOp(op.op) === "db-lock") base = "low";
|
|
95
|
+
else base = "medium";
|
|
96
|
+
if (effectiveLoopDepth >= 2) {
|
|
97
|
+
// nested loop escalates one level
|
|
98
|
+
if (base === "high") base = "critical";
|
|
99
|
+
else if (base === "medium") base = "high";
|
|
100
|
+
}
|
|
101
|
+
return base;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render the terminal op's target table for the rootCause string. Looks up the
|
|
106
|
+
* table NAME via `tableById` so the user sees `"Modify on Customer"` instead of
|
|
107
|
+
* the unhelpful internal id `"Modify on 437dbf0e-…/table/18"`. Falls back to
|
|
108
|
+
* the receiver's declared type name (with a `(type not loaded)` hint) when the
|
|
109
|
+
* tableId can't be resolved — see describeTable for the full tier list.
|
|
110
|
+
*/
|
|
111
|
+
function tableNote(
|
|
112
|
+
op: RecordOperation,
|
|
113
|
+
routine: Routine | undefined,
|
|
114
|
+
tableById: Map<TableId, Table>,
|
|
115
|
+
): string {
|
|
116
|
+
return `${op.op} on ${describeTable(op, routine, tableById)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Synthesise a RecordOperation from a DbEffect for routines whose raw features have been
|
|
121
|
+
* stripped (dependency-role artifact projections). The loopStack is empty because the
|
|
122
|
+
* depth is tracked by the path-walker's initialLoopDepth / localLoopDepth accounting.
|
|
123
|
+
*/
|
|
124
|
+
function synthRecordOpFromEffect(
|
|
125
|
+
routineId: RoutineId,
|
|
126
|
+
routine: Routine,
|
|
127
|
+
effect: DbEffect,
|
|
128
|
+
): RecordOperation {
|
|
129
|
+
return {
|
|
130
|
+
id: effect.operationId,
|
|
131
|
+
routineId,
|
|
132
|
+
op: effect.op,
|
|
133
|
+
recordVariableName: "",
|
|
134
|
+
tableId: effect.tableId === "unknown" ? undefined : effect.tableId,
|
|
135
|
+
tempState: effect.tempState,
|
|
136
|
+
loopStack: [],
|
|
137
|
+
sourceAnchor: { ...routine.sourceAnchor, enclosingRoutineId: routineId },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildFinding(
|
|
142
|
+
loopRoutine: Routine,
|
|
143
|
+
representativeLoop: LoopId,
|
|
144
|
+
result: WalkResult,
|
|
145
|
+
terminalOp: RecordOperation,
|
|
146
|
+
routineById: Map<RoutineId, Routine>,
|
|
147
|
+
tableById: Map<TableId, Table>,
|
|
148
|
+
model: SemanticModel,
|
|
149
|
+
): Finding {
|
|
150
|
+
const terminalRoutine = routineById.get(terminalOp.routineId);
|
|
151
|
+
const setupSingleton = isSetupSingletonGet(terminalOp, terminalRoutine, tableById);
|
|
152
|
+
const severity = severityFor(terminalOp, result.effectiveLoopDepth, setupSingleton);
|
|
153
|
+
const tempNote =
|
|
154
|
+
terminalOp.tempState.kind === "known" && terminalOp.tempState.value === true
|
|
155
|
+
? " (temporary record — not a SQL round-trip)"
|
|
156
|
+
: terminalOp.tempState.kind !== "known"
|
|
157
|
+
? " (temp state uncertain)"
|
|
158
|
+
: "";
|
|
159
|
+
const setupNote = setupSingleton
|
|
160
|
+
? " (Setup singleton — BC caches Get() per session, so the round-trip happens at most once.)"
|
|
161
|
+
: "";
|
|
162
|
+
|
|
163
|
+
// Two keys, two purposes:
|
|
164
|
+
// `id` per-(loop, op) — used by the existing within-walker dedup that
|
|
165
|
+
// drops a path the path-walker enumerated twice via different
|
|
166
|
+
// call-site branches.
|
|
167
|
+
// `rootCauseKey` per-(terminal-op) — used by mergeByTerminal at the end of
|
|
168
|
+
// detectD1 to fold M different ancestor loops reaching the same
|
|
169
|
+
// op into ONE finding with the others in additionalPaths. The
|
|
170
|
+
// bug entity is the terminal DB op, not the (loop, op) pair.
|
|
171
|
+
const finding: Finding = {
|
|
172
|
+
id: `d1/${representativeLoop}/${terminalOp.routineId}/${terminalOp.id}`,
|
|
173
|
+
rootCauseKey: `d1/${terminalOp.routineId}/${terminalOp.id}`,
|
|
174
|
+
detector: "d1-db-op-in-loop",
|
|
175
|
+
title: "Database operation inside a loop",
|
|
176
|
+
rootCause: `A loop in ${loopRoutine.name} reaches ${tableNote(terminalOp, terminalRoutine, tableById)}${tempNote}${setupNote}.`,
|
|
177
|
+
severity,
|
|
178
|
+
confidence: toConfidence(result.uncertainties, "likely"),
|
|
179
|
+
primaryLocation: terminalOp.sourceAnchor,
|
|
180
|
+
evidencePath: result.path,
|
|
181
|
+
affectedObjects: [
|
|
182
|
+
...new Set(
|
|
183
|
+
[loopRoutine.objectId, terminalRoutine?.objectId].filter(
|
|
184
|
+
(x): x is string => x !== undefined,
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
].sort(),
|
|
188
|
+
affectedTables: terminalOp.tableId !== undefined ? [terminalOp.tableId] : [],
|
|
189
|
+
fixOptions: setupSingleton
|
|
190
|
+
? [
|
|
191
|
+
{
|
|
192
|
+
description:
|
|
193
|
+
"Setup tables are session-cached by BC, so a Get() inside a loop is typically O(1) after the first hit. Hoist the Get() outside the loop only if the call site shows up in a CPU profile.",
|
|
194
|
+
safety: "high",
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
: [
|
|
198
|
+
{
|
|
199
|
+
description:
|
|
200
|
+
"Move the database operation outside the loop, or batch it into a set-based operation.",
|
|
201
|
+
safety: "medium",
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
provenance: [{ source: "tree-sitter" }],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const actionable = pickActionableAnchor(finding, model);
|
|
208
|
+
if (actionable !== undefined) finding.actionableAnchor = actionable;
|
|
209
|
+
// Fingerprint deferred until AFTER mergeByTerminal — the merged finding's
|
|
210
|
+
// affectedObjects/affectedTables can grow (union across paths), and fingerprint
|
|
211
|
+
// includes affectedTables for edit-survival stability.
|
|
212
|
+
return finding;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** D1: find DB operations executed inside a loop — directly or through an in-loop call chain. */
|
|
216
|
+
export function detectD1(
|
|
217
|
+
model: SemanticModel,
|
|
218
|
+
graph: CombinedGraph,
|
|
219
|
+
ctx: DetectorContext,
|
|
220
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
221
|
+
const findings: Finding[] = [];
|
|
222
|
+
const { routineById } = ctx;
|
|
223
|
+
let candidatesConsidered = 0;
|
|
224
|
+
let skippedParseIncomplete = 0;
|
|
225
|
+
let downgradedToInfo = 0;
|
|
226
|
+
let downgradedSetupSingleton = 0;
|
|
227
|
+
let skippedOpaqueCallee = 0;
|
|
228
|
+
let skippedDynamicDispatch = 0;
|
|
229
|
+
|
|
230
|
+
const policy: WalkPolicy<D1Terminal> = {
|
|
231
|
+
terminalsAt: (node) => {
|
|
232
|
+
const r = routineById.get(node);
|
|
233
|
+
if (r === undefined) return [];
|
|
234
|
+
// Dep routines ship with EMPTY_FEATURES (artifact projection strips them);
|
|
235
|
+
// reconstruct in-loop DB terminals from their summary.dbEffects.
|
|
236
|
+
if (roleOf(r) !== "dependency") {
|
|
237
|
+
return r.features.recordOperations
|
|
238
|
+
.filter((op) => isDbTouchingClass(classifyOp(op.op)))
|
|
239
|
+
.map((op) => ({ routineId: node, localLoopDepth: op.loopStack.length, op }));
|
|
240
|
+
}
|
|
241
|
+
// Dependency routines have their raw features stripped in the artifact. Synthesize
|
|
242
|
+
// terminals from summary.dbEffects (direct effects only — transitive ones are not
|
|
243
|
+
// re-emitted at this node; they are accessible by expanding further).
|
|
244
|
+
const effects = r.summary?.dbEffects.filter(
|
|
245
|
+
(e) => e.via === "direct" && isDbTouchingClass(classifyOp(e.op)),
|
|
246
|
+
);
|
|
247
|
+
if (!effects || effects.length === 0) return [];
|
|
248
|
+
return effects.map((e) => ({
|
|
249
|
+
routineId: node,
|
|
250
|
+
localLoopDepth: 0,
|
|
251
|
+
op: synthRecordOpFromEffect(node, r, e),
|
|
252
|
+
}));
|
|
253
|
+
},
|
|
254
|
+
expand: (node) =>
|
|
255
|
+
(graph.edgesByFrom.get(node) ?? []).filter((e) => {
|
|
256
|
+
// event fan-out is D2's job
|
|
257
|
+
if (e.kind === "event-dispatch") return false;
|
|
258
|
+
const to = routineById.get(e.to);
|
|
259
|
+
return to?.summary !== undefined && touchesDbOf(to.summary) !== "no";
|
|
260
|
+
}),
|
|
261
|
+
buildHopStep: (edge) => {
|
|
262
|
+
const fromRoutine = routineById.get(edge.from);
|
|
263
|
+
const cs = fromRoutine?.features.callSites.find((c) => c.id === edge.callsiteId);
|
|
264
|
+
const toName = routineById.get(edge.to)?.name ?? edge.to;
|
|
265
|
+
const triggerNote =
|
|
266
|
+
edge.kind === "implicit-trigger" ? ` (via implicit ${toName} trigger)` : "";
|
|
267
|
+
return {
|
|
268
|
+
routineId: edge.from,
|
|
269
|
+
callsiteId: edge.callsiteId,
|
|
270
|
+
sourceAnchor: cs?.sourceAnchor ??
|
|
271
|
+
fromRoutine?.sourceAnchor ?? {
|
|
272
|
+
sourceUnitId: "",
|
|
273
|
+
range: { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 },
|
|
274
|
+
enclosingRoutineId: edge.from,
|
|
275
|
+
syntaxKind: "call",
|
|
276
|
+
},
|
|
277
|
+
note: `calls ${toName}${triggerNote}`,
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
buildTerminalStep: (t) => ({
|
|
281
|
+
routineId: t.routineId,
|
|
282
|
+
operationId: t.op.id,
|
|
283
|
+
sourceAnchor: t.op.sourceAnchor,
|
|
284
|
+
note: tableNote(t.op, routineById.get(t.routineId), ctx.tableById),
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
for (const routine of model.routines) {
|
|
289
|
+
if (roleOf(routine) !== "primary") continue;
|
|
290
|
+
if (!routine.bodyAvailable) continue;
|
|
291
|
+
if (routine.parseIncomplete) {
|
|
292
|
+
skippedParseIncomplete++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
candidatesConsidered++;
|
|
296
|
+
const loopById = new Map(routine.features.loops.map((l) => [l.id, l]));
|
|
297
|
+
|
|
298
|
+
// Record-vars that had a cursor opened before any loop — used to suppress in-loop
|
|
299
|
+
// `Next` on the same var (the cursor's natural advance, not N+1).
|
|
300
|
+
const cursorOpenedRecordVars = new Set<string>();
|
|
301
|
+
for (const op of routine.features.recordOperations) {
|
|
302
|
+
if (op.loopStack.length !== 0) continue;
|
|
303
|
+
if (!CURSOR_OPENER_OPS.has(op.op)) continue;
|
|
304
|
+
cursorOpenedRecordVars.add(op.recordVariableName.toLowerCase());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// (a) Direct in-loop DB ops within this routine — iterate ops, key on representative loop.
|
|
308
|
+
for (const op of routine.features.recordOperations) {
|
|
309
|
+
if (op.loopStack.length === 0) continue;
|
|
310
|
+
if (!isDbTouchingClass(classifyOp(op.op))) continue;
|
|
311
|
+
if (op.op === "Next" && cursorOpenedRecordVars.has(op.recordVariableName.toLowerCase())) {
|
|
312
|
+
// FindSet/FindFirst/Find/FindLast on this var earlier → Next is the cursor advance.
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const representativeLoop = representativeLoopId(op.loopStack);
|
|
316
|
+
if (representativeLoop === undefined) continue;
|
|
317
|
+
const loop = loopById.get(representativeLoop);
|
|
318
|
+
if (loop === undefined) continue;
|
|
319
|
+
if (op.tempState.kind === "known" && op.tempState.value === true) {
|
|
320
|
+
downgradedToInfo++;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const loopStep: EvidenceStep = {
|
|
324
|
+
routineId: routine.id,
|
|
325
|
+
loopId: loop.id,
|
|
326
|
+
sourceAnchor: loop.sourceAnchor,
|
|
327
|
+
note: `${loop.type} loop`,
|
|
328
|
+
};
|
|
329
|
+
const opStep: EvidenceStep = {
|
|
330
|
+
routineId: routine.id,
|
|
331
|
+
operationId: op.id,
|
|
332
|
+
sourceAnchor: op.sourceAnchor,
|
|
333
|
+
note: tableNote(op, routine, ctx.tableById),
|
|
334
|
+
};
|
|
335
|
+
const result: WalkResult = {
|
|
336
|
+
path: [loopStep, opStep],
|
|
337
|
+
effectiveLoopDepth: op.loopStack.length,
|
|
338
|
+
// Always [] here: the op is directly observed in this routine — no call resolution.
|
|
339
|
+
uncertainties: [],
|
|
340
|
+
stop: "complete",
|
|
341
|
+
};
|
|
342
|
+
findings.push(
|
|
343
|
+
buildFinding(routine, representativeLoop, result, op, routineById, ctx.tableById, model),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// (b) In-loop calls to DB-touching callees — walk the call chain.
|
|
348
|
+
for (const cs of routine.features.callSites) {
|
|
349
|
+
if (cs.loopStack.length === 0) continue;
|
|
350
|
+
const representativeLoop = representativeLoopId(cs.loopStack);
|
|
351
|
+
if (representativeLoop === undefined) continue;
|
|
352
|
+
const loop = loopById.get(representativeLoop);
|
|
353
|
+
if (loop === undefined) continue;
|
|
354
|
+
|
|
355
|
+
const edge = (graph.edgesByFrom.get(routine.id) ?? []).find((e) => e.callsiteId === cs.id);
|
|
356
|
+
if (edge === undefined) {
|
|
357
|
+
// No resolved edge — opaque callee
|
|
358
|
+
skippedOpaqueCallee++;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (edge.kind === "interface" || edge.kind === "dynamic") {
|
|
362
|
+
skippedDynamicDispatch++;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const callsiteTo = routineById.get(edge.to);
|
|
366
|
+
if (callsiteTo?.summary === undefined || touchesDbOf(callsiteTo.summary) === "no") continue;
|
|
367
|
+
|
|
368
|
+
const loopStep: EvidenceStep = {
|
|
369
|
+
routineId: routine.id,
|
|
370
|
+
loopId: loop.id,
|
|
371
|
+
sourceAnchor: loop.sourceAnchor,
|
|
372
|
+
note: `${loop.type} loop`,
|
|
373
|
+
};
|
|
374
|
+
const callStep: EvidenceStep = {
|
|
375
|
+
routineId: routine.id,
|
|
376
|
+
callsiteId: cs.id,
|
|
377
|
+
sourceAnchor: cs.sourceAnchor,
|
|
378
|
+
note: `calls ${routineById.get(edge.to)?.name ?? edge.to}`,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const results = walkEvidence(edge.to, policy, BOUNDS, graph, model, {
|
|
382
|
+
initialLoopDepth: cs.loopStack.length,
|
|
383
|
+
initialSteps: [loopStep, callStep],
|
|
384
|
+
routineById,
|
|
385
|
+
uncertaintyEdgesByFrom: ctx.uncertaintyEdgesByFrom,
|
|
386
|
+
callSiteById: ctx.callSiteById,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
for (const result of results) {
|
|
390
|
+
if (result.stop !== "complete") continue;
|
|
391
|
+
const lastStep = result.path.at(-1);
|
|
392
|
+
if (lastStep?.operationId === undefined) continue;
|
|
393
|
+
const terminalRoutine = routineById.get(lastStep.routineId);
|
|
394
|
+
// Primary routines have real RecordOperations; dependency routines have theirs stripped
|
|
395
|
+
// in the artifact but preserve the operationId in summary.dbEffects.
|
|
396
|
+
const terminalOp: RecordOperation | undefined =
|
|
397
|
+
terminalRoutine?.features.recordOperations.find((o) => o.id === lastStep.operationId) ??
|
|
398
|
+
(() => {
|
|
399
|
+
const effect = terminalRoutine?.summary?.dbEffects.find(
|
|
400
|
+
(e) => e.operationId === lastStep.operationId,
|
|
401
|
+
);
|
|
402
|
+
if (!effect || !terminalRoutine) return undefined;
|
|
403
|
+
return synthRecordOpFromEffect(lastStep.routineId, terminalRoutine, effect);
|
|
404
|
+
})();
|
|
405
|
+
if (terminalOp === undefined) continue;
|
|
406
|
+
findings.push(
|
|
407
|
+
buildFinding(
|
|
408
|
+
routine,
|
|
409
|
+
representativeLoop,
|
|
410
|
+
result,
|
|
411
|
+
terminalOp,
|
|
412
|
+
routineById,
|
|
413
|
+
ctx.tableById,
|
|
414
|
+
model,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Two-stage collapse:
|
|
422
|
+
// 1. Dedupe by id (loop+op pair) — drops within-walker duplicates when the
|
|
423
|
+
// path-walker enumerates the same (loop, op) via different branches.
|
|
424
|
+
// 2. mergeByTerminal — folds different loops on the same terminal op into a
|
|
425
|
+
// single Finding with additionalPaths. Sorts by canonical id for
|
|
426
|
+
// determinism (a `rootCauseKey`-keyed sort is equivalent here).
|
|
427
|
+
const seen = new Set<string>();
|
|
428
|
+
const deduped: Finding[] = [];
|
|
429
|
+
for (const f of findings) {
|
|
430
|
+
if (seen.has(f.id)) continue;
|
|
431
|
+
seen.add(f.id);
|
|
432
|
+
deduped.push(f);
|
|
433
|
+
}
|
|
434
|
+
const merged = mergeByTerminal(deduped);
|
|
435
|
+
for (const f of merged) {
|
|
436
|
+
// Setup-singleton downgrades carry their note in rootCause — count them for stats
|
|
437
|
+
// (cheap signature check vs threading a counter through buildFinding + merge).
|
|
438
|
+
if (f.rootCause.includes("Setup singleton")) downgradedSetupSingleton++;
|
|
439
|
+
}
|
|
440
|
+
// Fingerprint AFTER merge — affectedObjects/affectedTables are unioned across
|
|
441
|
+
// paths, so the fingerprint needs the final values to be edit-stable.
|
|
442
|
+
for (const f of merged) f.fingerprint = fingerprintOf(f, model);
|
|
443
|
+
const sorted = merged.sort((a, b) => compareStrings(a.id, b.id));
|
|
444
|
+
const stats: DetectorStats = {
|
|
445
|
+
detector: "d1-db-op-in-loop",
|
|
446
|
+
candidatesConsidered,
|
|
447
|
+
findingsEmitted: sorted.length,
|
|
448
|
+
skipped: {
|
|
449
|
+
...(skippedOpaqueCallee > 0 ? { opaqueCallee: skippedOpaqueCallee } : {}),
|
|
450
|
+
...(skippedDynamicDispatch > 0 ? { dynamicDispatch: skippedDynamicDispatch } : {}),
|
|
451
|
+
...(skippedParseIncomplete > 0 ? { parseIncomplete: skippedParseIncomplete } : {}),
|
|
452
|
+
...(downgradedToInfo > 0 ? { downgradedToInfo } : {}),
|
|
453
|
+
...(downgradedSetupSingleton > 0 ? { downgradedSetupSingleton } : {}),
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
return { findings: sorted, stats };
|
|
457
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
5
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
6
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
7
|
+
import { toConfidence } from "./confidence.ts";
|
|
8
|
+
import type { DetectorContext } from "./detector-context.ts";
|
|
9
|
+
|
|
10
|
+
const MUTATING_OPS: ReadonlySet<string> = new Set([
|
|
11
|
+
"Modify",
|
|
12
|
+
"ModifyAll",
|
|
13
|
+
"Validate",
|
|
14
|
+
"Delete",
|
|
15
|
+
"DeleteAll",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The operation that drives cursor advancement in a repeat/until loop.
|
|
20
|
+
* `Next()` is always emitted inside the loop body, so it carries a non-empty
|
|
21
|
+
* loopStack — unlike `FindSet`/`FindFirst` which appear in the `if` guard
|
|
22
|
+
* before the `repeat` keyword and therefore have loopStack === [].
|
|
23
|
+
*/
|
|
24
|
+
const LOOP_DRIVER_OPS: ReadonlySet<string> = new Set(["Next"]);
|
|
25
|
+
|
|
26
|
+
export function detectD10(
|
|
27
|
+
model: SemanticModel,
|
|
28
|
+
_graph: CombinedGraph,
|
|
29
|
+
_ctx: DetectorContext,
|
|
30
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
31
|
+
const findings: Finding[] = [];
|
|
32
|
+
let candidatesConsidered = 0;
|
|
33
|
+
let skippedParseIncomplete = 0;
|
|
34
|
+
|
|
35
|
+
for (const routine of model.routines) {
|
|
36
|
+
if (roleOf(routine) !== "primary") continue;
|
|
37
|
+
if (!routine.bodyAvailable) continue;
|
|
38
|
+
if (routine.parseIncomplete) {
|
|
39
|
+
skippedParseIncomplete++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
candidatesConsidered++;
|
|
43
|
+
|
|
44
|
+
// Map loopId → record variable that drives the loop.
|
|
45
|
+
// We use Next() as the signal: it is always emitted inside the repeat/until body
|
|
46
|
+
// (loopStack is non-empty), whereas FindSet/FindFirst appear in the `if` guard
|
|
47
|
+
// before the `repeat` keyword and therefore have loopStack === [].
|
|
48
|
+
const loopDriver = new Map<string, string>();
|
|
49
|
+
for (const op of routine.features.recordOperations) {
|
|
50
|
+
if (!LOOP_DRIVER_OPS.has(op.op)) continue;
|
|
51
|
+
const loop = op.loopStack[op.loopStack.length - 1];
|
|
52
|
+
if (loop === undefined) continue;
|
|
53
|
+
if (!loopDriver.has(loop)) loopDriver.set(loop, op.recordVariableName.toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const op of routine.features.recordOperations) {
|
|
57
|
+
if (!MUTATING_OPS.has(op.op)) continue;
|
|
58
|
+
const loop = op.loopStack[op.loopStack.length - 1];
|
|
59
|
+
if (loop === undefined) continue;
|
|
60
|
+
const driver = loopDriver.get(loop);
|
|
61
|
+
if (driver === undefined) continue;
|
|
62
|
+
if (op.recordVariableName.toLowerCase() !== driver) continue;
|
|
63
|
+
|
|
64
|
+
const loopNode = routine.features.loops.find((l) => l.id === loop);
|
|
65
|
+
const path: EvidenceStep[] = [];
|
|
66
|
+
if (loopNode) {
|
|
67
|
+
path.push({
|
|
68
|
+
routineId: routine.id,
|
|
69
|
+
loopId: loopNode.id,
|
|
70
|
+
sourceAnchor: loopNode.sourceAnchor,
|
|
71
|
+
note: `${loopNode.type} loop iterating ${op.recordVariableName}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
path.push({
|
|
75
|
+
routineId: routine.id,
|
|
76
|
+
operationId: op.id,
|
|
77
|
+
sourceAnchor: op.sourceAnchor,
|
|
78
|
+
note: `${op.op} on iterating record ${op.recordVariableName}`,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const finding: Finding = {
|
|
82
|
+
id: `d10/${routine.id}/${op.id}`,
|
|
83
|
+
rootCauseKey: `d10/${routine.id}/${op.id}`,
|
|
84
|
+
detector: "d10-self-modifying-loop",
|
|
85
|
+
title: "Self-modifying loop",
|
|
86
|
+
rootCause: `${routine.name} runs ${op.op} on the iterating record ${op.recordVariableName} inside its own loop — the cursor's snapshot may be corrupted.`,
|
|
87
|
+
severity: "high",
|
|
88
|
+
confidence: toConfidence([], "likely"),
|
|
89
|
+
primaryLocation: op.sourceAnchor,
|
|
90
|
+
evidencePath: path,
|
|
91
|
+
affectedObjects: [routine.objectId],
|
|
92
|
+
affectedTables: op.tableId !== undefined ? [op.tableId] : [],
|
|
93
|
+
fixOptions: [
|
|
94
|
+
{
|
|
95
|
+
description:
|
|
96
|
+
"Collect the keys first, then iterate a fresh recordset to perform the modifications; or use ModifyAll with a filter.",
|
|
97
|
+
safety: "medium",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
provenance: [{ source: "tree-sitter" }],
|
|
101
|
+
};
|
|
102
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
103
|
+
findings.push(finding);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stats: DetectorStats = {
|
|
108
|
+
detector: "d10-self-modifying-loop",
|
|
109
|
+
candidatesConsidered,
|
|
110
|
+
findingsEmitted: findings.length,
|
|
111
|
+
skipped: { parseIncomplete: skippedParseIncomplete > 0 ? skippedParseIncomplete : undefined },
|
|
112
|
+
};
|
|
113
|
+
return { findings: findings.sort((a, b) => compareStrings(a.id, b.id)), stats };
|
|
114
|
+
}
|