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,243 @@
|
|
|
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 { type RecordOperation, roleOf } from "../model/entities.ts";
|
|
5
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
6
|
+
import type { FieldId } from "../model/ids.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 RELOAD_OPS: ReadonlySet<string> = new Set(["Reset"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute the source-ordered narrowed-load fingerprint for a record variable AT a
|
|
16
|
+
* given anchor (typically a callsite's `argumentAnchor`). Returns:
|
|
17
|
+
* - `"full"` — no prior SetLoadFields/AddLoadFields op exists on this var before
|
|
18
|
+
* the anchor (the variable carries the full record);
|
|
19
|
+
* - `string[]` — the cumulative narrow that the most recent SetLoadFields plus any
|
|
20
|
+
* subsequent AddLoadFields imposed;
|
|
21
|
+
* - `"unknown"` — a `Reset` between the last narrow and the anchor (Reset wipes
|
|
22
|
+
* the pending-narrow per spec) leaves the load shape unknowable
|
|
23
|
+
* here without walker support; treat as unknown to stay sound.
|
|
24
|
+
*
|
|
25
|
+
* Source-ordered, intra-routine. Mirrors D36's approach (the post-load placement
|
|
26
|
+
* detector) so detectors operating on the same flat features stay consistent.
|
|
27
|
+
*
|
|
28
|
+
* NOTE: this is a straight-line approximation. A `SetLoadFields` placed inside one
|
|
29
|
+
* branch of an `if`/`case` is treated as if it always applies — same control-flow-
|
|
30
|
+
* blindness carry-forward as D39/D41/D36. The Phase 6 walker handles this correctly
|
|
31
|
+
* for parameters; the per-callsite snapshot is not yet propagated to RoutineSummary
|
|
32
|
+
* (see the detector docstring's carry-forward note).
|
|
33
|
+
*/
|
|
34
|
+
function computeNarrowAtCallsite(
|
|
35
|
+
ops: readonly RecordOperation[],
|
|
36
|
+
varNameLc: string,
|
|
37
|
+
callsiteAnchor: { range: { startLine: number; startColumn: number } },
|
|
38
|
+
): FieldId[] | "full" | "unknown" {
|
|
39
|
+
let pending: FieldId[] | "none" | "unknown" = "none";
|
|
40
|
+
for (const op of ops) {
|
|
41
|
+
if (op.recordVariableName.toLowerCase() !== varNameLc) continue;
|
|
42
|
+
if (!beforeAnchor(op.sourceAnchor, callsiteAnchor)) continue;
|
|
43
|
+
if (op.op === "SetLoadFields") {
|
|
44
|
+
const fields = [...new Set(op.fieldArguments ?? [])].sort() as FieldId[];
|
|
45
|
+
pending = fields;
|
|
46
|
+
} else if (op.op === "AddLoadFields") {
|
|
47
|
+
const additions = op.fieldArguments ?? [];
|
|
48
|
+
if (pending === "none") {
|
|
49
|
+
pending = [...new Set(additions)].sort() as FieldId[];
|
|
50
|
+
} else if (pending === "unknown") {
|
|
51
|
+
// stay unknown
|
|
52
|
+
} else {
|
|
53
|
+
pending = [...new Set([...pending, ...additions])].sort() as FieldId[];
|
|
54
|
+
}
|
|
55
|
+
} else if (RELOAD_OPS.has(op.op)) {
|
|
56
|
+
// Reset wipes pendingNarrow (spec §(b1) / control-flow-walker.ts:904-907).
|
|
57
|
+
pending = "unknown";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (pending === "none") return "full";
|
|
61
|
+
if (pending === "unknown") return "unknown";
|
|
62
|
+
return pending;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* D42 — cross-call wrong SetLoadFields.
|
|
67
|
+
*
|
|
68
|
+
* At each resolved call edge that forwards a record to a callee var-parameter:
|
|
69
|
+
* - caller-side narrow LF (computed as the source-ordered cumulative
|
|
70
|
+
* SetLoadFields/AddLoadFields on the forwarded variable at the callsite, or
|
|
71
|
+
* falling back to caller-parameter `currentLoadedFieldsAtExit` from the walker
|
|
72
|
+
* when the source is the routine's own parameter) is a concrete list;
|
|
73
|
+
* - callee `parameterRoles[Q].requiredLoadedFieldsAtEntry` is a non-empty
|
|
74
|
+
* concrete list RF;
|
|
75
|
+
* - RF \ LF is non-empty;
|
|
76
|
+
* then the runtime will issue an extra SQL round-trip to fetch the missing fields,
|
|
77
|
+
* silently defeating the partial-load optimisation the caller was paying the
|
|
78
|
+
* complexity for.
|
|
79
|
+
*
|
|
80
|
+
* Severity: low (perf hygiene — measurable but small cost). Confidence: likely
|
|
81
|
+
* (both sides concrete; opaque callees and "unknown" walker outputs are skipped).
|
|
82
|
+
*
|
|
83
|
+
* Anchor: caller's argumentAnchor. rootCauseKey: routine + callsite + parameter index
|
|
84
|
+
* (stable across edits that move lines, unstable only if the routine or call boundary
|
|
85
|
+
* itself changes).
|
|
86
|
+
*
|
|
87
|
+
* Skip counters:
|
|
88
|
+
* - `callerFull` — caller-side LF is the "full" sentinel (no narrow at all);
|
|
89
|
+
* - `calleeRequiresNone` — callee's RF is empty or `"unknown"`;
|
|
90
|
+
* - `calleeUnknown` — callee has no parameterRole for this binding index.
|
|
91
|
+
*
|
|
92
|
+
* Carry-forward (precision TODO): the Phase 6 walker computes a per-callsite
|
|
93
|
+
* snapshot `currentLoadedFieldsAtCallsite` on `PathAwareFacts` (control-flow-
|
|
94
|
+
* walker.ts), but `summary-runner.ts` does NOT propagate it to
|
|
95
|
+
* `RoutineSummary.parameterRoles`. The detector therefore uses (a) a local
|
|
96
|
+
* source-ordered scan over `recordOperations` (this routine's load-narrow history
|
|
97
|
+
* on the forwarded variable) for local-record sources, and (b) the conservative
|
|
98
|
+
* `currentLoadedFieldsAtExit` fallback when the source IS the routine's own
|
|
99
|
+
* parameter. The latter is sound for narrows not subsequently widened (the
|
|
100
|
+
* dominant pattern) but can miss a narrow-then-widen-then-forward sequence (FN).
|
|
101
|
+
* Tightening this is a Phase 6 follow-on: surface the per-callsite snapshot on
|
|
102
|
+
* RecordRoleSummary and prefer it when present.
|
|
103
|
+
*
|
|
104
|
+
* Carry-forward (case-folded field-name comparison): the `RF.filter((f) =>
|
|
105
|
+
* !loaded.includes(f))` check below compares FieldId strings case-sensitively.
|
|
106
|
+
* AL field identifiers are case-insensitive at the language level, so a
|
|
107
|
+
* SetLoadFields list spelled `"no."` and a callee `requiredLoadedFieldsAtEntry`
|
|
108
|
+
* entry of `"No."` (or vice versa) would currently look like a missing field
|
|
109
|
+
* and emit a false positive. Field names are sourced from table metadata on
|
|
110
|
+
* both sides today so the mismatch is rare in practice, but a case-folded
|
|
111
|
+
* compare would harden the detector against author-style FPs. Tracked in
|
|
112
|
+
* STATUS.md Phase 4 carry-forwards; intentionally NOT implemented here so the
|
|
113
|
+
* change ships in its own scoped commit with targeted fixtures.
|
|
114
|
+
*
|
|
115
|
+
* Why this is al-sem-only:
|
|
116
|
+
* - Requires resolved call graph + per-callsite argument binding to know who
|
|
117
|
+
* forwards what.
|
|
118
|
+
* - Requires the callee's `requiredLoadedFieldsAtEntry` — a path-aware walker
|
|
119
|
+
* fact composed bottom-up over the SCC condensation. A per-file analyzer has
|
|
120
|
+
* no view of either side of the boundary.
|
|
121
|
+
* - Compares concrete FieldId lists derived from `op.fieldArguments[]` resolved
|
|
122
|
+
* against table metadata, not bare identifier text.
|
|
123
|
+
*/
|
|
124
|
+
export function detectD42(
|
|
125
|
+
model: SemanticModel,
|
|
126
|
+
_graph: CombinedGraph,
|
|
127
|
+
ctx: DetectorContext,
|
|
128
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
129
|
+
const findings: Finding[] = [];
|
|
130
|
+
const { routineById, resolvedCallEdgeByCallsite } = ctx;
|
|
131
|
+
|
|
132
|
+
let candidatesConsidered = 0;
|
|
133
|
+
let skippedCallerFull = 0;
|
|
134
|
+
let skippedCalleeRequiresNone = 0;
|
|
135
|
+
let skippedCalleeUnknown = 0;
|
|
136
|
+
|
|
137
|
+
for (const routine of model.routines) {
|
|
138
|
+
if (roleOf(routine) !== "primary") continue;
|
|
139
|
+
if (!routine.bodyAvailable) continue;
|
|
140
|
+
if (routine.parseIncomplete) continue;
|
|
141
|
+
const ownRole = routine.summary?.parameterRoles ?? [];
|
|
142
|
+
const ops = routine.features.recordOperations;
|
|
143
|
+
|
|
144
|
+
for (const cs of routine.features.callSites) {
|
|
145
|
+
const edge = resolvedCallEdgeByCallsite.get(cs.id);
|
|
146
|
+
if (edge?.to === undefined) continue;
|
|
147
|
+
const callee = routineById.get(edge.to);
|
|
148
|
+
if (callee === undefined) continue;
|
|
149
|
+
|
|
150
|
+
for (const binding of cs.argumentBindings) {
|
|
151
|
+
if (binding.bindingResolution !== "resolved") continue;
|
|
152
|
+
const calleeRole = callee.summary?.parameterRoles.find(
|
|
153
|
+
(r) => r.parameterIndex === binding.parameterIndex,
|
|
154
|
+
);
|
|
155
|
+
if (calleeRole === undefined) {
|
|
156
|
+
skippedCalleeUnknown++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const RF = calleeRole.requiredLoadedFieldsAtEntry;
|
|
160
|
+
if (RF === "unknown" || RF.length === 0) {
|
|
161
|
+
skippedCalleeRequiresNone++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Caller-side LF — two-tier resolution. Prefer the intra-routine
|
|
166
|
+
// source-ordered scan when the source is a local record-var (the
|
|
167
|
+
// walker doesn't track locals). Fall back to the caller-parameter
|
|
168
|
+
// walker fact `currentLoadedFieldsAtExit` when the source is the
|
|
169
|
+
// routine's own parameter.
|
|
170
|
+
let LF: FieldId[] | "full" | "unknown" = "unknown";
|
|
171
|
+
const sourceNameLc = binding.sourceVariableName;
|
|
172
|
+
if (sourceNameLc !== undefined) {
|
|
173
|
+
LF = computeNarrowAtCallsite(ops, sourceNameLc, cs.sourceAnchor);
|
|
174
|
+
}
|
|
175
|
+
if (LF === "unknown" && binding.sourceParameterIndex !== undefined) {
|
|
176
|
+
const callerRole = ownRole.find((r) => r.parameterIndex === binding.sourceParameterIndex);
|
|
177
|
+
LF = callerRole?.currentLoadedFieldsAtExit ?? "unknown";
|
|
178
|
+
}
|
|
179
|
+
if (LF === "unknown") continue;
|
|
180
|
+
if (LF === "full") {
|
|
181
|
+
skippedCallerFull++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const loaded = LF; // narrow to FieldId[]
|
|
185
|
+
const missing = RF.filter((f) => !loaded.includes(f));
|
|
186
|
+
if (missing.length === 0) continue;
|
|
187
|
+
candidatesConsidered++;
|
|
188
|
+
|
|
189
|
+
const path: EvidenceStep[] = [
|
|
190
|
+
{
|
|
191
|
+
routineId: routine.id,
|
|
192
|
+
callsiteId: cs.id,
|
|
193
|
+
sourceAnchor: binding.argumentAnchor,
|
|
194
|
+
note: `forwards ${binding.sourceVariableName ?? "record"} (narrowed to ${loaded.join(", ")}) to ${callee.name}`,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
routineId: callee.id,
|
|
198
|
+
sourceAnchor: callee.sourceAnchor,
|
|
199
|
+
note: `${callee.name} requires ${missing.join(", ")} loaded; the runtime will issue an extra SQL round-trip`,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const finding: Finding = {
|
|
204
|
+
id: `d42/${routine.id}/${cs.id}/${binding.parameterIndex}`,
|
|
205
|
+
rootCauseKey: `d42/${routine.id}/${cs.id}/${binding.parameterIndex}`,
|
|
206
|
+
detector: "d42-cross-call-wrong-setloadfields",
|
|
207
|
+
title: "Forwarded record's narrowed load misses a field the callee reads",
|
|
208
|
+
rootCause: `${routine.name} narrowed ${binding.sourceVariableName ?? "the record"}'s load to ${loaded.join(", ")} but forwards it to ${callee.name}, which reads ${missing.join(", ")} — defeats the partial-load optimisation.`,
|
|
209
|
+
severity: "low",
|
|
210
|
+
confidence: toConfidence([], "likely"),
|
|
211
|
+
primaryLocation: binding.argumentAnchor,
|
|
212
|
+
evidencePath: path,
|
|
213
|
+
affectedObjects: [routine.objectId, callee.objectId].sort(),
|
|
214
|
+
affectedTables: [],
|
|
215
|
+
fixOptions: [
|
|
216
|
+
{
|
|
217
|
+
description: `Add ${missing.join(", ")} to the SetLoadFields/AddLoadFields call on ${binding.sourceVariableName ?? "the record"} before forwarding to ${callee.name}.`,
|
|
218
|
+
safety: "high",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
provenance: [{ source: "tree-sitter" }],
|
|
222
|
+
};
|
|
223
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
224
|
+
findings.push(finding);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const sorted = findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
230
|
+
return {
|
|
231
|
+
findings: sorted,
|
|
232
|
+
stats: {
|
|
233
|
+
detector: "d42-cross-call-wrong-setloadfields",
|
|
234
|
+
candidatesConsidered,
|
|
235
|
+
findingsEmitted: sorted.length,
|
|
236
|
+
skipped: {
|
|
237
|
+
...(skippedCallerFull > 0 ? { callerFull: skippedCallerFull } : {}),
|
|
238
|
+
...(skippedCalleeRequiresNone > 0 ? { calleeRequiresNone: skippedCalleeRequiresNone } : {}),
|
|
239
|
+
...(skippedCalleeUnknown > 0 ? { calleeUnknown: skippedCalleeUnknown } : {}),
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { CombinedGraph } from "../engine/combined-graph.ts";
|
|
2
|
+
import { type DispatchSite, enumerateDispatchSites } from "../engine/dispatch-sites.ts";
|
|
3
|
+
import {
|
|
4
|
+
IS_HANDLED_RE,
|
|
5
|
+
buildCrossExtensionSubscribers,
|
|
6
|
+
eventKindOf,
|
|
7
|
+
} from "../engine/event-flow.ts";
|
|
8
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
9
|
+
import type { ControlFlowNode, Routine } from "../model/entities.ts";
|
|
10
|
+
import type { DetectorStats, EvidenceStep, Finding } from "../model/finding.ts";
|
|
11
|
+
import type { SourceAnchor } from "../model/identity.ts";
|
|
12
|
+
import type { EventId, RoutineId, TableId } from "../model/ids.ts";
|
|
13
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
14
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
15
|
+
import { writesTablesOf } from "./capability-query.ts";
|
|
16
|
+
import type { DetectorContext } from "./detector-context.ts";
|
|
17
|
+
|
|
18
|
+
export const D43_NAME = "d43-event-ishandled-skip";
|
|
19
|
+
|
|
20
|
+
type SetterClassification = "mustSetTrue" | "maySetTrue" | "noSetTrue";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns true when the assignment's source range is found inside a conditional
|
|
24
|
+
* branch (if/while/case/case-branch/repeat/for/foreach) in the routine's
|
|
25
|
+
* `statementTree`. Top-level assignments — direct children of the root block —
|
|
26
|
+
* return false (not nested).
|
|
27
|
+
*
|
|
28
|
+
* Algorithm: depth-first walk of the CFN tree. We track whether we are currently
|
|
29
|
+
* descending into the BODY of a conditional/loop node. When we encounter a node
|
|
30
|
+
* whose `sourceAnchor` matches the assignment anchor by start position, we record
|
|
31
|
+
* whether we are in a conditional context at that point.
|
|
32
|
+
*
|
|
33
|
+
* "Matches" is start-position equality (line + column) — the tree guarantees
|
|
34
|
+
* unique statement positions within a routine body.
|
|
35
|
+
*/
|
|
36
|
+
function isAssignmentNestedInTree(
|
|
37
|
+
assignmentAnchor: SourceAnchor,
|
|
38
|
+
tree: ControlFlowNode | undefined,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!tree) return false; // no tree → can't determine → conservative: NOT nested
|
|
41
|
+
const a = assignmentAnchor.range;
|
|
42
|
+
let result = false;
|
|
43
|
+
|
|
44
|
+
const CONDITIONAL_KINDS = new Set([
|
|
45
|
+
"if",
|
|
46
|
+
"while",
|
|
47
|
+
"repeat",
|
|
48
|
+
"for",
|
|
49
|
+
"foreach",
|
|
50
|
+
"case",
|
|
51
|
+
"case-branch",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
function visit(node: ControlFlowNode, inConditional: boolean): void {
|
|
55
|
+
if (result) return; // early exit once found
|
|
56
|
+
const r = node.sourceAnchor.range;
|
|
57
|
+
if (r.startLine === a.startLine && r.startColumn === a.startColumn) {
|
|
58
|
+
// This node IS the assignment statement.
|
|
59
|
+
result = inConditional;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Recurse into children. Children of a conditional/loop body are inside a branch.
|
|
63
|
+
const childConditional = inConditional || CONDITIONAL_KINDS.has(node.kind);
|
|
64
|
+
for (const c of node.children ?? []) {
|
|
65
|
+
visit(c, childConditional);
|
|
66
|
+
if (result) return;
|
|
67
|
+
}
|
|
68
|
+
for (const c of node.elseChildren ?? []) {
|
|
69
|
+
visit(c, true); // else-branch is always conditional
|
|
70
|
+
if (result) return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
visit(tree, false);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function classifySubscriber(
|
|
79
|
+
subscriber: RoutineId,
|
|
80
|
+
routineById: Map<RoutineId, Routine>,
|
|
81
|
+
): SetterClassification {
|
|
82
|
+
const r = routineById.get(subscriber);
|
|
83
|
+
if (!r) return "noSetTrue";
|
|
84
|
+
const sets = (r.features.varAssignments ?? []).filter(
|
|
85
|
+
(a) => IS_HANDLED_RE.test(a.lhsName) && a.rhsLiteralValue === "true",
|
|
86
|
+
);
|
|
87
|
+
if (sets.length === 0) return "noSetTrue";
|
|
88
|
+
// Phase 3.x refinement: classify as mustSetTrue when the assignment is at top
|
|
89
|
+
// level (not nested in any if/while/case/repeat-until/loop). hasBranching === false
|
|
90
|
+
// trivially means top-level — fast-path without CFN traversal.
|
|
91
|
+
if (r.features.hasBranching === false) return "mustSetTrue";
|
|
92
|
+
// If ANY setter is at top level (not nested in a conditional), the routine
|
|
93
|
+
// guarantees IsHandled=true on every path → mustSetTrue.
|
|
94
|
+
for (const setter of sets) {
|
|
95
|
+
if (!isAssignmentNestedInTree(setter.sourceAnchor, r.features.statementTree)) {
|
|
96
|
+
return "mustSetTrue";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return "maySetTrue";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function classifyConfidence(
|
|
103
|
+
site: DispatchSite,
|
|
104
|
+
setter: SetterClassification,
|
|
105
|
+
): Finding["confidence"]["level"] {
|
|
106
|
+
if (site.postCallGuards.length === 0) return "possible";
|
|
107
|
+
if (setter === "mustSetTrue" && site.handledActual !== undefined) return "confirmed";
|
|
108
|
+
if (setter === "maySetTrue") return "likely";
|
|
109
|
+
return "possible";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function detectD43(
|
|
113
|
+
model: SemanticModel,
|
|
114
|
+
_graph: CombinedGraph,
|
|
115
|
+
ctx: DetectorContext,
|
|
116
|
+
): { findings: Finding[]; stats: DetectorStats } {
|
|
117
|
+
const ix = ctx.getEventFlowIndexes();
|
|
118
|
+
const findings: Finding[] = [];
|
|
119
|
+
let candidates = 0;
|
|
120
|
+
let skippedNoGuard = 0;
|
|
121
|
+
let skippedNoSetter = 0;
|
|
122
|
+
|
|
123
|
+
// Substrate guard: if no routine has any conditionReference AND there are
|
|
124
|
+
// event subscribers (Phase 3 T10 idiom), emit ONE warning + bail.
|
|
125
|
+
const sawAnyConditionRef = model.routines.some(
|
|
126
|
+
(r) => (r.features.conditionReferences?.length ?? 0) > 0,
|
|
127
|
+
);
|
|
128
|
+
const eventSubscriberCount = model.routines.filter((r) => r.kind === "event-subscriber").length;
|
|
129
|
+
if (!sawAnyConditionRef && eventSubscriberCount > 0) {
|
|
130
|
+
ctx.diagnostics.push({
|
|
131
|
+
severity: "warning",
|
|
132
|
+
stage: "detect",
|
|
133
|
+
message: `${D43_NAME}: conditionReferences substrate empty; dispatch-site detection limited`,
|
|
134
|
+
});
|
|
135
|
+
return finalize();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const eventKindById = new Map<EventId, "integration" | "business" | "internal">();
|
|
139
|
+
for (const ev of model.eventGraph.events) {
|
|
140
|
+
eventKindById.set(ev.id as EventId, eventKindOf(ev.eventKind));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Phase 3.3: cross-extension subscriber lookup per event.
|
|
144
|
+
const crossExtByEvent = buildCrossExtensionSubscribers(model);
|
|
145
|
+
|
|
146
|
+
const sites = enumerateDispatchSites(model, ix);
|
|
147
|
+
for (const site of sites) {
|
|
148
|
+
if (site.postCallGuards.length === 0) {
|
|
149
|
+
skippedNoGuard++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (site.guardedTablesWritten.length === 0) {
|
|
153
|
+
skippedNoGuard++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const subs = ix.subscribersByEvent.get(site.eventId) ?? [];
|
|
157
|
+
const setters: Array<{ sub: RoutineId; classification: SetterClassification }> = [];
|
|
158
|
+
for (const sub of subs) {
|
|
159
|
+
const c = classifySubscriber(sub, ctx.routineById);
|
|
160
|
+
if (c !== "noSetTrue") setters.push({ sub, classification: c });
|
|
161
|
+
}
|
|
162
|
+
if (setters.length === 0) {
|
|
163
|
+
skippedNoSetter++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const guardedSet = new Set<string>(site.guardedTablesWritten);
|
|
167
|
+
// Coverage candidates: subs that write at least one of the guarded tables.
|
|
168
|
+
const coverageCandidates: RoutineId[] = [];
|
|
169
|
+
for (const sub of subs) {
|
|
170
|
+
const r = ctx.routineById.get(sub);
|
|
171
|
+
if (!r?.summary) continue;
|
|
172
|
+
if (writesTablesOf(r.summary).some((t) => guardedSet.has(t))) {
|
|
173
|
+
coverageCandidates.push(sub);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const { sub: setter, classification } of setters) {
|
|
177
|
+
candidates++;
|
|
178
|
+
const r = ctx.routineById.get(setter);
|
|
179
|
+
if (!r?.summary) continue;
|
|
180
|
+
const setterWrites = new Set(writesTablesOf(r.summary));
|
|
181
|
+
const missing = site.guardedTablesWritten.filter((t) => !setterWrites.has(t));
|
|
182
|
+
if (missing.length === 0) continue;
|
|
183
|
+
const coverageStatus: "candidate-coverage" | "no-other-writers" =
|
|
184
|
+
coverageCandidates.length > 0 ? "candidate-coverage" : "no-other-writers";
|
|
185
|
+
const severityBase: Finding["severity"] =
|
|
186
|
+
coverageStatus === "candidate-coverage" ? "medium" : "high";
|
|
187
|
+
const confidenceLevel = classifyConfidence(site, classification);
|
|
188
|
+
const severity: Finding["severity"] =
|
|
189
|
+
confidenceLevel === "possible"
|
|
190
|
+
? severityBase === "high"
|
|
191
|
+
? "medium"
|
|
192
|
+
: severityBase === "medium"
|
|
193
|
+
? "low"
|
|
194
|
+
: severityBase
|
|
195
|
+
: severityBase;
|
|
196
|
+
const caller = ctx.routineById.get(site.callerRoutine);
|
|
197
|
+
for (const table of missing) {
|
|
198
|
+
const rootCauseKey = `d43/${site.eventId}|${site.callerRoutine}|${site.callsiteId}|${setter}|${table}`;
|
|
199
|
+
const evidence: EvidenceStep[] = [
|
|
200
|
+
{
|
|
201
|
+
routineId: site.callerRoutine,
|
|
202
|
+
callsiteId: site.callsiteId,
|
|
203
|
+
sourceAnchor: caller?.sourceAnchor ?? r.sourceAnchor,
|
|
204
|
+
note: `dispatch site for ${site.eventId}; guard via ${site.handledActual?.variableName ?? "IsHandled"}`,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
routineId: setter,
|
|
208
|
+
sourceAnchor: r.sourceAnchor,
|
|
209
|
+
note: `subscriber ${classification === "mustSetTrue" ? "always" : "may"} set IsHandled := true`,
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
const crossExtSubs = crossExtByEvent.get(site.eventId as EventId);
|
|
213
|
+
const finding: Finding = {
|
|
214
|
+
id: rootCauseKey,
|
|
215
|
+
rootCauseKey,
|
|
216
|
+
detector: D43_NAME,
|
|
217
|
+
title:
|
|
218
|
+
"Event subscriber sets IsHandled but does not perform the publisher's default write",
|
|
219
|
+
rootCause: `Caller ${site.callerRoutine} guards table writes on IsHandled; subscriber ${setter} sets it true but doesn't write ${table}. coverage=${coverageStatus}`,
|
|
220
|
+
severity,
|
|
221
|
+
confidence: { level: confidenceLevel, evidence: [] },
|
|
222
|
+
primaryLocation: r.sourceAnchor,
|
|
223
|
+
evidencePath: evidence,
|
|
224
|
+
affectedObjects: [],
|
|
225
|
+
affectedTables: [table] as TableId[],
|
|
226
|
+
eventKind: eventKindById.get(site.eventId as EventId),
|
|
227
|
+
crossExtensionSubscribers:
|
|
228
|
+
crossExtSubs !== undefined && crossExtSubs.length > 0 ? crossExtSubs : undefined,
|
|
229
|
+
fixOptions: [
|
|
230
|
+
{
|
|
231
|
+
description:
|
|
232
|
+
"Either perform the missing write in the subscriber, or stop setting IsHandled := true.",
|
|
233
|
+
safety: "high",
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
provenance: [{ source: "tree-sitter" }],
|
|
237
|
+
};
|
|
238
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
239
|
+
findings.push(finding);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return finalize();
|
|
244
|
+
|
|
245
|
+
function finalize(): { findings: Finding[]; stats: DetectorStats } {
|
|
246
|
+
findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
247
|
+
return {
|
|
248
|
+
findings,
|
|
249
|
+
stats: {
|
|
250
|
+
detector: D43_NAME,
|
|
251
|
+
candidatesConsidered: candidates,
|
|
252
|
+
findingsEmitted: findings.length,
|
|
253
|
+
skipped: { other: skippedNoGuard + skippedNoSetter },
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|