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,157 @@
|
|
|
1
|
+
// src/detectors/d17-min-version-drift.ts
|
|
2
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
3
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
4
|
+
import { roleOf } from "../model/entities.ts";
|
|
5
|
+
import type { DetectorStats, 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
|
+
/**
|
|
12
|
+
* Compare two BC version strings ("X.Y.Z.W"). Returns -1, 0, 1. Missing components
|
|
13
|
+
* are treated as 0 so "24" < "24.1" < "24.1.0.0" correctly.
|
|
14
|
+
*/
|
|
15
|
+
function cmpVersion(a: string, b: string): number {
|
|
16
|
+
const pa = a.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
17
|
+
const pb = b.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
18
|
+
const len = Math.max(pa.length, pb.length);
|
|
19
|
+
for (let i = 0; i < len; i++) {
|
|
20
|
+
const da = pa[i] ?? 0;
|
|
21
|
+
const db = pb[i] ?? 0;
|
|
22
|
+
if (da !== db) return da < db ? -1 : 1;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* D17 — MinVersion vs resolved-Version drift (Outcome-B precision).
|
|
29
|
+
*
|
|
30
|
+
* For each dep declared in the primary app's app.json dependencies[], compare the
|
|
31
|
+
* declared minVersion against the actual resolved app version
|
|
32
|
+
* (SemanticModel.apps[].version). If resolved > declared AND the primary app
|
|
33
|
+
* actually calls into that dep, emit one info finding per drifting dep.
|
|
34
|
+
*
|
|
35
|
+
* Per-routine sinceVersion is deferred: SymbolReference.json doesn't carry it.
|
|
36
|
+
*/
|
|
37
|
+
export function detectD17(
|
|
38
|
+
model: SemanticModel,
|
|
39
|
+
graph: CombinedGraph,
|
|
40
|
+
ctx: DetectorContext,
|
|
41
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
42
|
+
const findings: Finding[] = [];
|
|
43
|
+
const declared = model.identity.primaryDependencies ?? [];
|
|
44
|
+
if (declared.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
findings,
|
|
47
|
+
stats: {
|
|
48
|
+
detector: "d17-min-version-drift",
|
|
49
|
+
candidatesConsidered: 0,
|
|
50
|
+
findingsEmitted: 0,
|
|
51
|
+
skipped: {},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const appByGuid = new Map(model.apps.map((a) => [a.appGuid, a]));
|
|
57
|
+
const objectsById = ctx.objectsById;
|
|
58
|
+
const routinesById = ctx.routineById;
|
|
59
|
+
|
|
60
|
+
const calledDepGuids = new Set<string>();
|
|
61
|
+
const sampleCallsiteByDep = new Map<
|
|
62
|
+
string,
|
|
63
|
+
{
|
|
64
|
+
callerRoutineId: string;
|
|
65
|
+
calleeRoutineName: string;
|
|
66
|
+
anchor: (typeof model.routines)[number]["sourceAnchor"];
|
|
67
|
+
}
|
|
68
|
+
>();
|
|
69
|
+
for (const [, edges] of graph.edgesByFrom) {
|
|
70
|
+
for (const e of edges) {
|
|
71
|
+
const caller = routinesById.get(e.from);
|
|
72
|
+
const callee = routinesById.get(e.to);
|
|
73
|
+
if (!caller || !callee) continue;
|
|
74
|
+
if (roleOf(caller) !== "primary") continue;
|
|
75
|
+
const callerObj = objectsById.get(caller.objectId);
|
|
76
|
+
const calleeObj = objectsById.get(callee.objectId);
|
|
77
|
+
if (!callerObj || !calleeObj) continue;
|
|
78
|
+
if (callerObj.appGuid === calleeObj.appGuid) continue;
|
|
79
|
+
calledDepGuids.add(calleeObj.appGuid);
|
|
80
|
+
if (!sampleCallsiteByDep.has(calleeObj.appGuid)) {
|
|
81
|
+
const cs = caller.features.callSites.find((c) => c.id === e.callsiteId);
|
|
82
|
+
sampleCallsiteByDep.set(calleeObj.appGuid, {
|
|
83
|
+
callerRoutineId: caller.id,
|
|
84
|
+
calleeRoutineName: callee.name,
|
|
85
|
+
anchor: cs?.sourceAnchor ?? caller.sourceAnchor,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let candidatesConsidered = 0;
|
|
92
|
+
let skippedOther = 0;
|
|
93
|
+
for (const dep of declared) {
|
|
94
|
+
candidatesConsidered++;
|
|
95
|
+
const resolved = appByGuid.get(dep.appGuid);
|
|
96
|
+
if (resolved === undefined) {
|
|
97
|
+
skippedOther++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (cmpVersion(resolved.version, dep.minVersion) <= 0) {
|
|
101
|
+
skippedOther++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!calledDepGuids.has(dep.appGuid)) {
|
|
105
|
+
skippedOther++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const sample = sampleCallsiteByDep.get(dep.appGuid);
|
|
109
|
+
if (sample === undefined) {
|
|
110
|
+
skippedOther++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const callerRoutine = routinesById.get(sample.callerRoutineId);
|
|
114
|
+
if (!callerRoutine) {
|
|
115
|
+
skippedOther++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const finding: Finding = {
|
|
119
|
+
id: `d17/${dep.appGuid}`,
|
|
120
|
+
rootCauseKey: `d17/${dep.appGuid}`,
|
|
121
|
+
detector: "d17-min-version-drift",
|
|
122
|
+
title: "Declared MinVersion is older than the resolved dependency version",
|
|
123
|
+
rootCause: `${callerRoutine.name} calls into ${dep.name} (${dep.appGuid}). app.json declares MinVersion ${dep.minVersion} for this dependency, but the resolved .app is at version ${resolved.version} — your code may use APIs that don't exist on older tenants.`,
|
|
124
|
+
severity: "info",
|
|
125
|
+
confidence: toConfidence([], "possible"),
|
|
126
|
+
primaryLocation: sample.anchor,
|
|
127
|
+
evidencePath: [
|
|
128
|
+
{
|
|
129
|
+
routineId: sample.callerRoutineId,
|
|
130
|
+
sourceAnchor: sample.anchor,
|
|
131
|
+
note: `calls ${sample.calleeRoutineName} in ${dep.name} ${resolved.version} (declared MinVersion ${dep.minVersion})`,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
affectedObjects: [callerRoutine.objectId].sort(),
|
|
135
|
+
affectedTables: [],
|
|
136
|
+
fixOptions: [
|
|
137
|
+
{
|
|
138
|
+
description: `Bump app.json dependencies[].version for ${dep.name} to at least ${resolved.version}, or test that your code paths into this dep also work on ${dep.minVersion}.`,
|
|
139
|
+
safety: "medium",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
provenance: [{ source: "tree-sitter" }],
|
|
143
|
+
};
|
|
144
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
145
|
+
findings.push(finding);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
findings: findings.sort((a, b) => compareStrings(a.id, b.id)),
|
|
150
|
+
stats: {
|
|
151
|
+
detector: "d17-min-version-drift",
|
|
152
|
+
candidatesConsidered,
|
|
153
|
+
findingsEmitted: findings.length,
|
|
154
|
+
skipped: { other: skippedOther > 0 ? skippedOther : undefined },
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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 { isLiteralExpression, unquotedFieldName } 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 FILTER_OPS: ReadonlySet<string> = new Set(["SetRange", "SetFilter"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A "literal" argument that does not depend on any loop variable — the same
|
|
16
|
+
* value is produced every iteration. Conservative matcher (see
|
|
17
|
+
* `model/expression.ts → isLiteralExpression`):
|
|
18
|
+
* - quoted string (`'value'` or `"value"`)
|
|
19
|
+
* - numeric literal (with optional unary `+` / `-`)
|
|
20
|
+
* - boolean (`true` / `false`)
|
|
21
|
+
* - enum literal `Type::Member`
|
|
22
|
+
*
|
|
23
|
+
* Identifiers and calls fall through to non-literal — we cannot prove the value
|
|
24
|
+
* is loop-invariant without dataflow we don't have. Better silent than wrong.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* D18 — flag `SetRange` / `SetFilter` inside a loop whose every argument after the
|
|
29
|
+
* field name is a literal. The same filter is applied every iteration with no
|
|
30
|
+
* dependency on the iterating variable; the call can be hoisted out of the loop.
|
|
31
|
+
*
|
|
32
|
+
* Skipped:
|
|
33
|
+
* - temporary records (`tempState: { kind: "known", value: true }`) — the state
|
|
34
|
+
* cost is in-memory and negligible;
|
|
35
|
+
* - any non-literal value argument — we cannot statically prove loop-invariance.
|
|
36
|
+
*
|
|
37
|
+
* Dedup key: (routineId, loopId, recordVar, fieldName) — multiple identical sets
|
|
38
|
+
* inside the same loop produce one finding.
|
|
39
|
+
*/
|
|
40
|
+
export function detectD18(
|
|
41
|
+
model: SemanticModel,
|
|
42
|
+
_graph: CombinedGraph,
|
|
43
|
+
_ctx: DetectorContext,
|
|
44
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
45
|
+
const findings: Finding[] = [];
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
let candidatesConsidered = 0;
|
|
48
|
+
let skippedNonLiteral = 0;
|
|
49
|
+
let skippedTempRecord = 0;
|
|
50
|
+
|
|
51
|
+
for (const routine of model.routines) {
|
|
52
|
+
if (roleOf(routine) !== "primary") continue;
|
|
53
|
+
if (!routine.bodyAvailable) continue;
|
|
54
|
+
if (routine.parseIncomplete) continue;
|
|
55
|
+
candidatesConsidered++;
|
|
56
|
+
|
|
57
|
+
const loopById = new Map(routine.features.loops.map((l) => [l.id, l]));
|
|
58
|
+
|
|
59
|
+
for (const op of routine.features.recordOperations) {
|
|
60
|
+
if (!FILTER_OPS.has(op.op)) continue;
|
|
61
|
+
if (op.loopStack.length === 0) continue;
|
|
62
|
+
if (op.tempState.kind === "known" && op.tempState.value === true) {
|
|
63
|
+
skippedTempRecord++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const infos = op.fieldArgumentInfos ?? [];
|
|
67
|
+
if (infos.length < 2) continue; // unrenderable — bail
|
|
68
|
+
const valueArgs = infos.slice(1);
|
|
69
|
+
if (!valueArgs.every(isLiteralExpression)) {
|
|
70
|
+
skippedNonLiteral++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const representativeLoop = op.loopStack.at(-1);
|
|
75
|
+
if (representativeLoop === undefined) continue;
|
|
76
|
+
const loop = loopById.get(representativeLoop);
|
|
77
|
+
if (loop === undefined) continue;
|
|
78
|
+
const recordVar = op.recordVariableName.toLowerCase();
|
|
79
|
+
const fieldInfo = infos[0];
|
|
80
|
+
if (fieldInfo === undefined) continue;
|
|
81
|
+
const fieldName = fieldInfo.text.trim();
|
|
82
|
+
const dedupKey = `${routine.id}|${loop.id}|${recordVar}|${unquotedFieldName(fieldInfo)}`;
|
|
83
|
+
if (seen.has(dedupKey)) continue;
|
|
84
|
+
seen.add(dedupKey);
|
|
85
|
+
|
|
86
|
+
emit(routine, loop, op, fieldName, findings, model);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
91
|
+
return {
|
|
92
|
+
findings: sorted,
|
|
93
|
+
stats: {
|
|
94
|
+
detector: "d18-constant-filter-in-loop",
|
|
95
|
+
candidatesConsidered,
|
|
96
|
+
findingsEmitted: sorted.length,
|
|
97
|
+
skipped: {
|
|
98
|
+
...(skippedNonLiteral > 0 ? { nonLiteralArgs: skippedNonLiteral } : {}),
|
|
99
|
+
...(skippedTempRecord > 0 ? { tempRecord: skippedTempRecord } : {}),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function emit(
|
|
106
|
+
routine: { id: string; objectId: string; name: string; sourceAnchor: unknown },
|
|
107
|
+
loop: { id: string; type: string; sourceAnchor: unknown },
|
|
108
|
+
op: RecordOperation,
|
|
109
|
+
fieldName: string,
|
|
110
|
+
findings: Finding[],
|
|
111
|
+
model: SemanticModel,
|
|
112
|
+
): void {
|
|
113
|
+
const path: EvidenceStep[] = [
|
|
114
|
+
{
|
|
115
|
+
routineId: routine.id,
|
|
116
|
+
loopId: loop.id,
|
|
117
|
+
sourceAnchor: loop.sourceAnchor as EvidenceStep["sourceAnchor"],
|
|
118
|
+
note: `${loop.type} loop`,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
routineId: routine.id,
|
|
122
|
+
operationId: op.id,
|
|
123
|
+
sourceAnchor: op.sourceAnchor,
|
|
124
|
+
note: `${op.op}(${op.fieldArguments?.join(", ") ?? ""}) on ${op.recordVariableName}`,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const finding: Finding = {
|
|
129
|
+
id: `d18/${routine.id}/${loop.id}/${op.recordVariableName.toLowerCase()}/${fieldName.toLowerCase()}`,
|
|
130
|
+
rootCauseKey: `d18/${routine.id}/${loop.id}/${op.recordVariableName.toLowerCase()}/${fieldName.toLowerCase()}`,
|
|
131
|
+
detector: "d18-constant-filter-in-loop",
|
|
132
|
+
title: "Constant filter applied inside a loop",
|
|
133
|
+
rootCause: `${routine.name} calls ${op.op} on ${op.recordVariableName}.${fieldName} with literal arguments inside a ${loop.type} loop — the filter is identical every iteration and can be hoisted outside.`,
|
|
134
|
+
severity: "low",
|
|
135
|
+
confidence: toConfidence([], "likely"),
|
|
136
|
+
primaryLocation: op.sourceAnchor,
|
|
137
|
+
evidencePath: path,
|
|
138
|
+
affectedObjects: [routine.objectId],
|
|
139
|
+
affectedTables: op.tableId !== undefined ? [op.tableId] : [],
|
|
140
|
+
fixOptions: [
|
|
141
|
+
{
|
|
142
|
+
description:
|
|
143
|
+
"Move the SetRange/SetFilter call outside the loop. The filter state persists across iterations until reset or cleared.",
|
|
144
|
+
safety: "high",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
provenance: [{ source: "tree-sitter" }],
|
|
148
|
+
};
|
|
149
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
150
|
+
findings.push(finding);
|
|
151
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { Routine } 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
|
+
/**
|
|
12
|
+
* D19 — declared procedure parameter never referenced in the routine body.
|
|
13
|
+
*
|
|
14
|
+
* Restricted to forms where the signal is precise:
|
|
15
|
+
* - `procedure` only (NOT triggers, NOT event subscribers — the latter's signature
|
|
16
|
+
* is dictated by the publisher and must keep every parameter declared, even unused).
|
|
17
|
+
*
|
|
18
|
+
* Detection: the L2 indexer walks the routine body and collects every identifier
|
|
19
|
+
* referenced as a *value* into `features.identifierReferences` (lowercased, sorted,
|
|
20
|
+
* deduped). Field names, enum member names, and bare type names in enum-type
|
|
21
|
+
* position are excluded by construction. A parameter whose lowercased name is
|
|
22
|
+
* absent from that set is unreferenced.
|
|
23
|
+
*
|
|
24
|
+
* Severity: `info` — hygiene, not correctness.
|
|
25
|
+
*/
|
|
26
|
+
export function detectD19(
|
|
27
|
+
model: SemanticModel,
|
|
28
|
+
_graph: CombinedGraph,
|
|
29
|
+
_ctx: DetectorContext,
|
|
30
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
31
|
+
const findings: Finding[] = [];
|
|
32
|
+
let candidatesConsidered = 0;
|
|
33
|
+
let skippedEventSubscriber = 0;
|
|
34
|
+
let skippedTrigger = 0;
|
|
35
|
+
|
|
36
|
+
for (const routine of model.routines) {
|
|
37
|
+
if (roleOf(routine) !== "primary") continue;
|
|
38
|
+
if (!routine.bodyAvailable) continue;
|
|
39
|
+
if (routine.parseIncomplete) continue;
|
|
40
|
+
if (routine.kind === "trigger") {
|
|
41
|
+
skippedTrigger++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (routine.kind === "event-subscriber") {
|
|
45
|
+
skippedEventSubscriber++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (routine.parameters.length === 0) continue;
|
|
49
|
+
candidatesConsidered++;
|
|
50
|
+
|
|
51
|
+
const refs = new Set(routine.features.identifierReferences);
|
|
52
|
+
for (const param of routine.parameters) {
|
|
53
|
+
const lc = param.name.toLowerCase();
|
|
54
|
+
if (lc === "") continue; // unnamed slot — never flag
|
|
55
|
+
if (refs.has(lc)) continue;
|
|
56
|
+
const path: EvidenceStep[] = [
|
|
57
|
+
{
|
|
58
|
+
routineId: routine.id,
|
|
59
|
+
sourceAnchor: routine.sourceAnchor,
|
|
60
|
+
note: `parameter '${param.name}: ${param.typeText}' declared but never referenced`,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
findings.push(makeFinding(routine, param, path, model));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
68
|
+
return {
|
|
69
|
+
findings: sorted,
|
|
70
|
+
stats: {
|
|
71
|
+
detector: "d19-unused-parameter",
|
|
72
|
+
candidatesConsidered,
|
|
73
|
+
findingsEmitted: sorted.length,
|
|
74
|
+
skipped: {
|
|
75
|
+
...(skippedTrigger > 0 ? { trigger: skippedTrigger } : {}),
|
|
76
|
+
...(skippedEventSubscriber > 0 ? { eventSubscriber: skippedEventSubscriber } : {}),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeFinding(
|
|
83
|
+
routine: Routine,
|
|
84
|
+
param: Routine["parameters"][number],
|
|
85
|
+
path: EvidenceStep[],
|
|
86
|
+
model: SemanticModel,
|
|
87
|
+
): Finding {
|
|
88
|
+
const finding: Finding = {
|
|
89
|
+
id: `d19/${routine.id}/p${param.index}`,
|
|
90
|
+
rootCauseKey: `d19/${routine.id}/p${param.index}`,
|
|
91
|
+
detector: "d19-unused-parameter",
|
|
92
|
+
title: "Procedure parameter is never used",
|
|
93
|
+
rootCause: `${routine.name} declares parameter '${param.name}' (${param.typeText}) at position ${param.index} but the body never references it.`,
|
|
94
|
+
severity: "info",
|
|
95
|
+
// Confidence upgraded from "possible" to "likely" — `identifierReferences`
|
|
96
|
+
// covers scalar parameter uses too (the pre-PR-5 fragment scan only saw
|
|
97
|
+
// record/call/field-access positions, which is why scalar params were
|
|
98
|
+
// excluded entirely). Now record AND scalar params are checked from the
|
|
99
|
+
// same precise structural signal.
|
|
100
|
+
confidence: toConfidence([], "likely"),
|
|
101
|
+
primaryLocation: routine.sourceAnchor,
|
|
102
|
+
evidencePath: path,
|
|
103
|
+
affectedObjects: [routine.objectId],
|
|
104
|
+
affectedTables: [],
|
|
105
|
+
fixOptions: [
|
|
106
|
+
{
|
|
107
|
+
description:
|
|
108
|
+
"Remove the parameter, or wire it into the procedure body. If callers must keep the existing signature, leave it and silence with an `_` prefix on the name.",
|
|
109
|
+
safety: "low",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
provenance: [{ source: "tree-sitter" }],
|
|
113
|
+
};
|
|
114
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
115
|
+
return finding;
|
|
116
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
2
|
+
import { classifyOp, isDbTouchingClass } from "../engine/op-classification.ts";
|
|
3
|
+
import type { Terminal, WalkPolicy } from "../engine/path-walker.ts";
|
|
4
|
+
import { walkEvidence } from "../engine/path-walker.ts";
|
|
5
|
+
import { resolvePublishedEvent } from "../engine/summary-engine.ts";
|
|
6
|
+
import { compareStrings, dedupeUncertainties } from "../engine/uncertainty-util.ts";
|
|
7
|
+
import { roleOf } from "../model/entities.ts";
|
|
8
|
+
import type { RecordOperation } from "../model/entities.ts";
|
|
9
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
10
|
+
import type { RoutineId } from "../model/ids.ts";
|
|
11
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
12
|
+
import type { Uncertainty } from "../model/summary.ts";
|
|
13
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
14
|
+
import { touchesDbOf, writesTablesOf } 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
|
+
const BOUNDS = { maxDepth: 20, maxNodes: 500 };
|
|
21
|
+
|
|
22
|
+
interface D2Terminal extends Terminal {
|
|
23
|
+
op: RecordOperation;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** D2: find an event raised inside a loop whose subscribers touch the database. */
|
|
27
|
+
export function detectD2(
|
|
28
|
+
model: SemanticModel,
|
|
29
|
+
graph: CombinedGraph,
|
|
30
|
+
ctx: DetectorContext,
|
|
31
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
32
|
+
const findings: Finding[] = [];
|
|
33
|
+
const { routineById, objectsById: objectById } = ctx;
|
|
34
|
+
const publisherRoutineIds = new Set(
|
|
35
|
+
model.routines.filter((r) => r.kind === "event-publisher").map((r) => r.id),
|
|
36
|
+
);
|
|
37
|
+
let candidatesConsidered = 0;
|
|
38
|
+
let skippedOpaqueCallee = 0;
|
|
39
|
+
let skippedDynamicDispatch = 0;
|
|
40
|
+
let skippedParseIncomplete = 0;
|
|
41
|
+
let unresolvedSubscriber = 0;
|
|
42
|
+
|
|
43
|
+
const policy: WalkPolicy<D2Terminal> = {
|
|
44
|
+
terminalsAt: (node) => {
|
|
45
|
+
const r = routineById.get(node);
|
|
46
|
+
if (r === undefined) return [];
|
|
47
|
+
return r.features.recordOperations
|
|
48
|
+
.filter((op) => isDbTouchingClass(classifyOp(op.op)))
|
|
49
|
+
.map((op) => ({ routineId: node, localLoopDepth: op.loopStack.length, op }));
|
|
50
|
+
},
|
|
51
|
+
expand: (node) =>
|
|
52
|
+
(graph.edgesByFrom.get(node) ?? []).filter((e) => {
|
|
53
|
+
if (e.kind === "event-dispatch") return false;
|
|
54
|
+
const to = routineById.get(e.to);
|
|
55
|
+
return to?.summary !== undefined && touchesDbOf(to.summary) !== "no";
|
|
56
|
+
}),
|
|
57
|
+
buildHopStep: (edge) => {
|
|
58
|
+
const fromRoutine = routineById.get(edge.from);
|
|
59
|
+
const cs = fromRoutine?.features.callSites.find((c) => c.id === edge.callsiteId);
|
|
60
|
+
const toName = routineById.get(edge.to)?.name ?? edge.to;
|
|
61
|
+
const triggerNote =
|
|
62
|
+
edge.kind === "implicit-trigger" ? ` (via implicit ${toName} trigger)` : "";
|
|
63
|
+
return {
|
|
64
|
+
routineId: edge.from,
|
|
65
|
+
callsiteId: edge.callsiteId,
|
|
66
|
+
sourceAnchor: cs?.sourceAnchor ??
|
|
67
|
+
fromRoutine?.sourceAnchor ?? {
|
|
68
|
+
sourceUnitId: "",
|
|
69
|
+
range: { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 },
|
|
70
|
+
enclosingRoutineId: edge.from,
|
|
71
|
+
syntaxKind: "call",
|
|
72
|
+
},
|
|
73
|
+
note: `calls ${toName}${triggerNote}`,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
buildTerminalStep: (t) => ({
|
|
77
|
+
routineId: t.routineId,
|
|
78
|
+
operationId: t.op.id,
|
|
79
|
+
sourceAnchor: t.op.sourceAnchor,
|
|
80
|
+
note: `${t.op.op} on ${describeTable(t.op, routineById.get(t.routineId), ctx.tableById)}`,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const routine of model.routines) {
|
|
85
|
+
if (roleOf(routine) !== "primary") continue;
|
|
86
|
+
if (!routine.bodyAvailable) continue;
|
|
87
|
+
if (routine.parseIncomplete) {
|
|
88
|
+
skippedParseIncomplete++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
candidatesConsidered++;
|
|
92
|
+
for (const cs of routine.features.callSites) {
|
|
93
|
+
if (cs.loopStack.length === 0) continue; // publish must be inside a loop
|
|
94
|
+
const edge = (graph.edgesByFrom.get(routine.id) ?? []).find((e) => e.callsiteId === cs.id);
|
|
95
|
+
if (edge === undefined) {
|
|
96
|
+
// No resolved edge in a loop — opaque callee
|
|
97
|
+
skippedOpaqueCallee++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (edge.kind === "interface" || edge.kind === "dynamic") {
|
|
101
|
+
skippedDynamicDispatch++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!publisherRoutineIds.has(edge.to)) continue; // not an event publish
|
|
105
|
+
const eventId = resolvePublishedEvent(cs.operationId, model);
|
|
106
|
+
if (eventId === undefined) continue;
|
|
107
|
+
|
|
108
|
+
const subEdges = (graph.edgesByFrom.get(edge.to) ?? []).filter(
|
|
109
|
+
(e) => e.kind === "event-dispatch" && e.eventId === eventId,
|
|
110
|
+
);
|
|
111
|
+
const eventName = model.eventGraph.events.find((s) => s.id === eventId)?.eventName ?? eventId;
|
|
112
|
+
|
|
113
|
+
const loopId = cs.loopStack[cs.loopStack.length - 1];
|
|
114
|
+
const loopStep: EvidenceStep = {
|
|
115
|
+
routineId: routine.id,
|
|
116
|
+
loopId,
|
|
117
|
+
callsiteId: cs.id,
|
|
118
|
+
sourceAnchor: cs.sourceAnchor,
|
|
119
|
+
note: `loop raises event ${eventName}`,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const subscriberSteps: EvidenceStep[] = [];
|
|
123
|
+
const affectedObjects = new Set<string>([routine.objectId]);
|
|
124
|
+
const affectedTables = new Set<string>();
|
|
125
|
+
const uncertainties: Uncertainty[] = [];
|
|
126
|
+
let anyDbSubscriber = false;
|
|
127
|
+
let allResolved = true;
|
|
128
|
+
|
|
129
|
+
for (const subEdge of subEdges) {
|
|
130
|
+
if (subEdge.resolution !== "resolved") allResolved = false;
|
|
131
|
+
const subRoutine = routineById.get(subEdge.to);
|
|
132
|
+
if (subRoutine === undefined) {
|
|
133
|
+
unresolvedSubscriber++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!subRoutine.bodyAvailable) {
|
|
137
|
+
allResolved = false;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (subRoutine.summary === undefined || touchesDbOf(subRoutine.summary) === "no") continue;
|
|
141
|
+
// The subscriber's summary-level `touchesDb: "yes"` is the witness; the deep walk
|
|
142
|
+
// below is supplementary evidence that locates the exact op site.
|
|
143
|
+
const subSummary = subRoutine.summary;
|
|
144
|
+
anyDbSubscriber = true;
|
|
145
|
+
affectedObjects.add(subRoutine.objectId);
|
|
146
|
+
for (const u of subSummary.uncertainties) uncertainties.push(u);
|
|
147
|
+
for (const t of writesTablesOf(subSummary)) affectedTables.add(t);
|
|
148
|
+
const subObjectName = objectById.get(subRoutine.objectId)?.name ?? subRoutine.objectId;
|
|
149
|
+
subscriberSteps.push({
|
|
150
|
+
routineId: subRoutine.id,
|
|
151
|
+
sourceAnchor: subRoutine.sourceAnchor,
|
|
152
|
+
note: `subscriber ${subRoutine.name} in ${subObjectName} (app ${subEdge.subscriberAppId}) touches the database`,
|
|
153
|
+
});
|
|
154
|
+
const results = walkEvidence(subRoutine.id, policy, BOUNDS, graph, model, {
|
|
155
|
+
routineById,
|
|
156
|
+
uncertaintyEdgesByFrom: ctx.uncertaintyEdgesByFrom,
|
|
157
|
+
callSiteById: ctx.callSiteById,
|
|
158
|
+
});
|
|
159
|
+
const complete = results.find((r) => r.stop === "complete");
|
|
160
|
+
if (complete !== undefined) {
|
|
161
|
+
subscriberSteps.push(...complete.path);
|
|
162
|
+
for (const u of complete.uncertainties) uncertainties.push(u);
|
|
163
|
+
const term = complete.path.at(-1);
|
|
164
|
+
const termOp =
|
|
165
|
+
term?.operationId !== undefined
|
|
166
|
+
? routineById
|
|
167
|
+
.get(term.routineId)
|
|
168
|
+
?.features.recordOperations.find((o) => o.id === term.operationId)
|
|
169
|
+
: undefined;
|
|
170
|
+
if (termOp?.tableId !== undefined) affectedTables.add(termOp.tableId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!anyDbSubscriber) continue;
|
|
175
|
+
|
|
176
|
+
const baseLevel = allResolved ? "likely" : "possible";
|
|
177
|
+
// Two keys, two purposes (mirrors D1):
|
|
178
|
+
// `id` per-(loop, eventId) — drops within-walker duplicates
|
|
179
|
+
// when the same loop publishes the same event from two
|
|
180
|
+
// callsites.
|
|
181
|
+
// `rootCauseKey` per-eventId — used by mergeByTerminal to fold M
|
|
182
|
+
// different loops publishing the same event into ONE
|
|
183
|
+
// finding with the others in additionalPaths. The bug
|
|
184
|
+
// entity is the event with DB-touching subscribers; the
|
|
185
|
+
// loops are supporting traces. Fixing the subscribers
|
|
186
|
+
// cheap-ifies all callsites at once.
|
|
187
|
+
const d2finding: Finding = {
|
|
188
|
+
id: `d2/${loopId}/${eventId}`,
|
|
189
|
+
rootCauseKey: `d2/${eventId}`,
|
|
190
|
+
detector: "d2-event-fanout-in-loop",
|
|
191
|
+
title: "Event raised inside a loop fans out to database work",
|
|
192
|
+
rootCause: `${routine.name} raises ${eventName} inside a loop; subscribers touch the database every iteration.`,
|
|
193
|
+
severity: "high",
|
|
194
|
+
confidence: toConfidence(dedupeUncertainties(uncertainties), baseLevel),
|
|
195
|
+
primaryLocation: cs.sourceAnchor,
|
|
196
|
+
evidencePath: [loopStep, ...subscriberSteps],
|
|
197
|
+
affectedObjects: [...affectedObjects].sort(),
|
|
198
|
+
affectedTables: [...affectedTables].sort(),
|
|
199
|
+
fixOptions: [
|
|
200
|
+
{
|
|
201
|
+
description:
|
|
202
|
+
"Raise the event once outside the loop, or batch the work the subscribers do.",
|
|
203
|
+
safety: "medium",
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
provenance: [{ source: "tree-sitter" }],
|
|
207
|
+
};
|
|
208
|
+
// Fingerprint deferred until AFTER mergeByTerminal — affectedObjects /
|
|
209
|
+
// affectedTables are unioned across paths.
|
|
210
|
+
findings.push(d2finding);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Two-stage collapse (mirrors D1):
|
|
215
|
+
// 1. Dedupe by id (loop+event pair) — drops within-walker duplicates.
|
|
216
|
+
// 2. mergeByTerminal — folds different loops on the same event into a
|
|
217
|
+
// single Finding with additionalPaths.
|
|
218
|
+
const seen = new Set<string>();
|
|
219
|
+
const deduped: Finding[] = [];
|
|
220
|
+
for (const f of findings) {
|
|
221
|
+
if (seen.has(f.id)) continue;
|
|
222
|
+
seen.add(f.id);
|
|
223
|
+
deduped.push(f);
|
|
224
|
+
}
|
|
225
|
+
const merged = mergeByTerminal(deduped);
|
|
226
|
+
for (const f of merged) f.fingerprint = fingerprintOf(f, model);
|
|
227
|
+
const sorted = merged.sort((a, b) => compareStrings(a.id, b.id));
|
|
228
|
+
const stats: DetectorStats = {
|
|
229
|
+
detector: "d2-event-fanout-in-loop",
|
|
230
|
+
candidatesConsidered,
|
|
231
|
+
findingsEmitted: sorted.length,
|
|
232
|
+
skipped: {
|
|
233
|
+
...(skippedOpaqueCallee > 0 ? { opaqueCallee: skippedOpaqueCallee } : {}),
|
|
234
|
+
...(skippedDynamicDispatch > 0 ? { dynamicDispatch: skippedDynamicDispatch } : {}),
|
|
235
|
+
...(skippedParseIncomplete > 0 ? { parseIncomplete: skippedParseIncomplete } : {}),
|
|
236
|
+
...(unresolvedSubscriber > 0 ? { unresolvedSubscriber } : {}),
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
return { findings: sorted, stats };
|
|
240
|
+
}
|