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.
Files changed (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -0
  3. package/package.json +64 -0
  4. package/scripts/d40-diff.ts +44 -0
  5. package/scripts/fetch-native-parser.ts +179 -0
  6. package/scripts/precision-sample.ts +99 -0
  7. package/scripts/precision-study.ts +42 -0
  8. package/scripts/precision-tabulate.ts +52 -0
  9. package/src/cli/baseline.ts +31 -0
  10. package/src/cli/diff.ts +199 -0
  11. package/src/cli/events-chains.ts +56 -0
  12. package/src/cli/events-fanout.ts +87 -0
  13. package/src/cli/exit-code.ts +30 -0
  14. package/src/cli/fingerprint-indexes.ts +130 -0
  15. package/src/cli/fingerprint-query.ts +543 -0
  16. package/src/cli/fingerprint-witness.ts +493 -0
  17. package/src/cli/fingerprint.ts +292 -0
  18. package/src/cli/format-compact-json.ts +45 -0
  19. package/src/cli/format-events.ts +77 -0
  20. package/src/cli/format-fingerprint.ts +295 -0
  21. package/src/cli/format-html.ts +503 -0
  22. package/src/cli/format-json.ts +13 -0
  23. package/src/cli/format-policy.ts +95 -0
  24. package/src/cli/format-sarif.ts +186 -0
  25. package/src/cli/format-terminal.ts +153 -0
  26. package/src/cli/index.ts +566 -0
  27. package/src/cli/policy.ts +204 -0
  28. package/src/config/roots-config.ts +302 -0
  29. package/src/deps/cache-versions.ts +74 -0
  30. package/src/deps/canonical-json.ts +27 -0
  31. package/src/deps/dependency-artifact.ts +144 -0
  32. package/src/deps/dependency-cache.ts +262 -0
  33. package/src/deps/dependency-dag.ts +128 -0
  34. package/src/deps/dependency-package-discovery.ts +85 -0
  35. package/src/deps/dependency-pipeline.ts +483 -0
  36. package/src/deps/dependency-projection.ts +211 -0
  37. package/src/deps/dependency-resolver.ts +154 -0
  38. package/src/deps/workspace-dependencies.ts +114 -0
  39. package/src/detectors/capability-query.ts +145 -0
  40. package/src/detectors/confidence.ts +52 -0
  41. package/src/detectors/d1-db-op-in-loop.ts +457 -0
  42. package/src/detectors/d10-self-modifying-loop.ts +114 -0
  43. package/src/detectors/d11-modify-without-get.ts +129 -0
  44. package/src/detectors/d12-dead-integration-event.ts +81 -0
  45. package/src/detectors/d13-cross-app-internal-call.ts +105 -0
  46. package/src/detectors/d14-dead-routine.ts +151 -0
  47. package/src/detectors/d16-obsolete-routine-call.ts +94 -0
  48. package/src/detectors/d17-min-version-drift.ts +157 -0
  49. package/src/detectors/d18-constant-filter-in-loop.ts +151 -0
  50. package/src/detectors/d19-unused-parameter.ts +116 -0
  51. package/src/detectors/d2-event-fanout-in-loop.ts +240 -0
  52. package/src/detectors/d20-unreachable-after-exit.ts +92 -0
  53. package/src/detectors/d21-read-without-load.ts +128 -0
  54. package/src/detectors/d22-flowfield-without-calcfields.ts +168 -0
  55. package/src/detectors/d29-subscriber-modify-on-event-record.ts +163 -0
  56. package/src/detectors/d3-load-state.ts +72 -0
  57. package/src/detectors/d3-missing-setloadfields.ts +234 -0
  58. package/src/detectors/d32-constant-boolean-parameter.ts +185 -0
  59. package/src/detectors/d33-unfiltered-bulk-write.ts +173 -0
  60. package/src/detectors/d34-commit-in-loop.ts +206 -0
  61. package/src/detectors/d35-commit-in-event-subscriber.ts +138 -0
  62. package/src/detectors/d36-late-setloadfields.ts +162 -0
  63. package/src/detectors/d37-validate-without-persist.ts +271 -0
  64. package/src/detectors/d38-subscriber-to-obsolete-event.ts +140 -0
  65. package/src/detectors/d39-record-left-dirty-across-chain.ts +165 -0
  66. package/src/detectors/d4-repeated-lookup-in-loop.ts +128 -0
  67. package/src/detectors/d40-transitive-load-missing.ts +217 -0
  68. package/src/detectors/d41-transitive-filter-loss.ts +200 -0
  69. package/src/detectors/d42-cross-call-wrong-setloadfields.ts +243 -0
  70. package/src/detectors/d43-event-ishandled-skip.ts +257 -0
  71. package/src/detectors/d44-event-multi-subscriber-overlap.ts +223 -0
  72. package/src/detectors/d45-event-transitive-table-exposure.ts +159 -0
  73. package/src/detectors/d5-set-based-opportunity.ts +162 -0
  74. package/src/detectors/d7-recursive-event-expansion.ts +151 -0
  75. package/src/detectors/d8-commit-in-transaction.ts +132 -0
  76. package/src/detectors/d9-transaction-span-summary.ts +107 -0
  77. package/src/detectors/detector-context.ts +121 -0
  78. package/src/detectors/finding-grouping.ts +61 -0
  79. package/src/detectors/path-merge.ts +174 -0
  80. package/src/detectors/registry.ts +176 -0
  81. package/src/detectors/table-display.ts +42 -0
  82. package/src/diff/diff-abi.ts +195 -0
  83. package/src/diff/diff-capabilities.ts +179 -0
  84. package/src/diff/diff-engine.ts +146 -0
  85. package/src/diff/diff-events.ts +323 -0
  86. package/src/diff/diff-identity.ts +73 -0
  87. package/src/diff/diff-indexes.ts +199 -0
  88. package/src/diff/diff-permissions.ts +260 -0
  89. package/src/diff/diff-policy.ts +101 -0
  90. package/src/diff/diff-preflight.ts +66 -0
  91. package/src/diff/diff-renames.ts +104 -0
  92. package/src/diff/diff-schema.ts +232 -0
  93. package/src/diff/format-diff.ts +148 -0
  94. package/src/engine/attribute-parser.ts +50 -0
  95. package/src/engine/capability-cone.ts +531 -0
  96. package/src/engine/combined-graph.ts +357 -0
  97. package/src/engine/control-flow-walker.ts +1317 -0
  98. package/src/engine/dispatch-sites.ts +199 -0
  99. package/src/engine/effect-lattice.ts +81 -0
  100. package/src/engine/entry-points.ts +57 -0
  101. package/src/engine/event-flow.ts +524 -0
  102. package/src/engine/event-relay.ts +92 -0
  103. package/src/engine/op-classification.ts +92 -0
  104. package/src/engine/path-walker.ts +189 -0
  105. package/src/engine/reverse-call-graph.ts +23 -0
  106. package/src/engine/root-classifier-overlay.ts +194 -0
  107. package/src/engine/root-classifier.ts +135 -0
  108. package/src/engine/scc.ts +110 -0
  109. package/src/engine/source-anchor.ts +25 -0
  110. package/src/engine/summary-context.ts +104 -0
  111. package/src/engine/summary-engine.ts +296 -0
  112. package/src/engine/summary-runner.ts +560 -0
  113. package/src/engine/transaction-spans.ts +112 -0
  114. package/src/engine/uncertainty-util.ts +54 -0
  115. package/src/hash.ts +31 -0
  116. package/src/index/attribute-from-node.ts +141 -0
  117. package/src/index/callee-from-node.ts +181 -0
  118. package/src/index/capability/background.ts +90 -0
  119. package/src/index/capability/commit.ts +44 -0
  120. package/src/index/capability/dispatch.ts +164 -0
  121. package/src/index/capability/events.ts +65 -0
  122. package/src/index/capability/extractor.ts +124 -0
  123. package/src/index/capability/file-blob.ts +137 -0
  124. package/src/index/capability/http.ts +159 -0
  125. package/src/index/capability/hyperlink.ts +60 -0
  126. package/src/index/capability/isolated-storage.ts +179 -0
  127. package/src/index/capability/table.ts +113 -0
  128. package/src/index/capability/telemetry.ts +84 -0
  129. package/src/index/capability/ui.ts +55 -0
  130. package/src/index/capability/value-source.ts +202 -0
  131. package/src/index/expression-from-node.ts +117 -0
  132. package/src/index/indexer.ts +102 -0
  133. package/src/index/intraprocedural-body.ts +1467 -0
  134. package/src/index/intraprocedural-ops.ts +253 -0
  135. package/src/index/intraprocedural-refs.ts +188 -0
  136. package/src/index/object-indexer.ts +279 -0
  137. package/src/index/routine-indexer.ts +282 -0
  138. package/src/index/routine-signature.ts +46 -0
  139. package/src/index/variable-indexer.ts +134 -0
  140. package/src/index/variable-initializer-extractor.ts +155 -0
  141. package/src/index/variable-type-normalizer.ts +83 -0
  142. package/src/index.ts +267 -0
  143. package/src/mcp/server.ts +72 -0
  144. package/src/mcp/session.ts +49 -0
  145. package/src/mcp/tools/explain-path.ts +75 -0
  146. package/src/mcp/tools/get-analysis-health.ts +62 -0
  147. package/src/mcp/tools/get-finding.ts +47 -0
  148. package/src/mcp/tools/get-routine-summary.ts +126 -0
  149. package/src/mcp/tools/list-findings.ts +85 -0
  150. package/src/mcp/tools/list-hotspots.ts +78 -0
  151. package/src/mcp/tools/list-rollups.ts +103 -0
  152. package/src/mcp/tools/validators.ts +25 -0
  153. package/src/model/attributes.ts +120 -0
  154. package/src/model/callee.ts +45 -0
  155. package/src/model/capability.ts +187 -0
  156. package/src/model/coverage.ts +85 -0
  157. package/src/model/entities.ts +628 -0
  158. package/src/model/expression.ts +98 -0
  159. package/src/model/finding.ts +110 -0
  160. package/src/model/graph-edge.ts +93 -0
  161. package/src/model/graph.ts +62 -0
  162. package/src/model/identity.ts +81 -0
  163. package/src/model/ids.ts +90 -0
  164. package/src/model/index.ts +13 -0
  165. package/src/model/model.ts +51 -0
  166. package/src/model/permission.ts +76 -0
  167. package/src/model/root-classification.ts +116 -0
  168. package/src/model/stable-identity.ts +102 -0
  169. package/src/model/summary.ts +96 -0
  170. package/src/parser/ast.ts +82 -0
  171. package/src/parser/native/ffi.ts +145 -0
  172. package/src/parser/native/parse-index-pool.ts +148 -0
  173. package/src/parser/native/parse-index-worker.ts +94 -0
  174. package/src/parser/native/wrapper.ts +353 -0
  175. package/src/parser/parser-init.ts +43 -0
  176. package/src/perf/profiler.ts +66 -0
  177. package/src/policy/policy-default.yaml +83 -0
  178. package/src/policy/policy-engine.ts +339 -0
  179. package/src/policy/policy-loader.ts +257 -0
  180. package/src/policy/policy-schema.json +379 -0
  181. package/src/policy/policy-types.ts +81 -0
  182. package/src/policy/predicate-compiler.ts +151 -0
  183. package/src/policy/predicate-evaluator.ts +267 -0
  184. package/src/policy/predicate-fields.ts +439 -0
  185. package/src/projection/actionable-anchor.ts +48 -0
  186. package/src/projection/finding-filters.ts +44 -0
  187. package/src/projection/finding-fingerprint.ts +54 -0
  188. package/src/projection/finding-groups.ts +41 -0
  189. package/src/projection/finding-summary.ts +110 -0
  190. package/src/projection/rollup-findings.ts +105 -0
  191. package/src/providers/discover.ts +88 -0
  192. package/src/providers/external.ts +46 -0
  193. package/src/providers/types.ts +36 -0
  194. package/src/providers/workspace.ts +117 -0
  195. package/src/resolve/call-resolver.ts +117 -0
  196. package/src/resolve/coverage.ts +61 -0
  197. package/src/resolve/event-graph.ts +166 -0
  198. package/src/resolve/implicit-edges.ts +53 -0
  199. package/src/resolve/record-types.ts +36 -0
  200. package/src/resolve/resolver.ts +23 -0
  201. package/src/resolve/semantic-graph.ts +29 -0
  202. package/src/resolve/symbol-table.ts +69 -0
  203. package/src/snapshot/app-snapshot.ts +74 -0
  204. package/src/snapshot/compose.ts +100 -0
  205. package/src/snapshot/derive/callsite-evidence.ts +76 -0
  206. package/src/snapshot/derive/capability-facts.ts +70 -0
  207. package/src/snapshot/derive/contracts.ts +131 -0
  208. package/src/snapshot/derive/coverage.ts +35 -0
  209. package/src/snapshot/derive/event-declarations.ts +140 -0
  210. package/src/snapshot/derive/identity-table.ts +58 -0
  211. package/src/snapshot/derive/inputs.ts +91 -0
  212. package/src/snapshot/derive/operation-evidence.ts +70 -0
  213. package/src/snapshot/derive/permissions.ts +186 -0
  214. package/src/snapshot/derive/root-classifications.ts +56 -0
  215. package/src/snapshot/derive/schema.ts +130 -0
  216. package/src/snapshot/derive/typed-edges.ts +60 -0
  217. package/src/snapshot/derive/workspace-fingerprint.ts +19 -0
  218. package/src/snapshot/deserialize.ts +40 -0
  219. package/src/snapshot/serialize-cbor-gz.ts +12 -0
  220. package/src/snapshot/serialize-cbor.ts +19 -0
  221. package/src/snapshot/serialize-json.ts +22 -0
  222. package/src/snapshot/shard.ts +134 -0
  223. package/src/snapshot/types.ts +181 -0
  224. package/src/symbols/app-manifest.ts +96 -0
  225. package/src/symbols/app-package-zip.ts +50 -0
  226. package/src/symbols/embedded-source-reader.ts +41 -0
  227. package/src/symbols/package-hash.ts +81 -0
  228. package/src/symbols/symbol-reader.ts +101 -0
  229. package/src/symbols/symbol-reference-parser.ts +378 -0
  230. package/src/symbols/symbol-reference-reader.ts +27 -0
  231. package/tsconfig.json +18 -0
@@ -0,0 +1,48 @@
1
+ import { type Routine, roleOf } from "../model/entities.ts";
2
+ import type { EvidenceStep, Finding } from "../model/finding.ts";
3
+ import type { SourceAnchor } from "../model/identity.ts";
4
+ import type { RoutineId } from "../model/ids.ts";
5
+ import type { SemanticModel } from "../model/model.ts";
6
+
7
+ /**
8
+ * Per-model routine index, memoized by model identity. `pickActionableAnchor` is called once
9
+ * per finding by detectors that build many findings (D1 builds hundreds); rebuilding the 100k+
10
+ * routine map per call dominated. The model is immutable during detection, so a WeakMap keyed
11
+ * on it builds the map once and reuses it; entries are GC'd with the model.
12
+ */
13
+ const routinesByModel = new WeakMap<SemanticModel, Map<RoutineId, Routine>>();
14
+
15
+ function routinesFor(model: SemanticModel): Map<RoutineId, Routine> {
16
+ let m = routinesByModel.get(model);
17
+ if (m === undefined) {
18
+ m = new Map(model.routines.map((r) => [r.id, r]));
19
+ routinesByModel.set(model, m);
20
+ }
21
+ return m;
22
+ }
23
+
24
+ /**
25
+ * Pick the first evidence step whose owning routine is in the primary app.
26
+ * Walk forward through `evidencePath`; returns undefined when primaryLocation
27
+ * is already primary (caller leaves `actionableAnchor` unset to signal "use
28
+ * primaryLocation as-is").
29
+ */
30
+ export function pickActionableAnchor(
31
+ finding: Pick<Finding, "primaryLocation" | "evidencePath">,
32
+ model: SemanticModel,
33
+ ): SourceAnchor | undefined {
34
+ const routinesById = routinesFor(model);
35
+ const isPrimaryRoutine = (id: RoutineId): boolean => {
36
+ const r = routinesById.get(id);
37
+ return r !== undefined && roleOf(r) === "primary";
38
+ };
39
+
40
+ // primaryLocation.enclosingRoutineId is the terminal routine — if it's primary, no anchor needed.
41
+ if (isPrimaryRoutine(finding.primaryLocation.enclosingRoutineId)) return undefined;
42
+
43
+ // Walk the evidence path; return the first step that belongs to a primary routine.
44
+ for (const step of finding.evidencePath) {
45
+ if (isPrimaryRoutine((step as EvidenceStep).routineId)) return step.sourceAnchor;
46
+ }
47
+ return undefined;
48
+ }
@@ -0,0 +1,44 @@
1
+ import type { FindingSummary } from "./finding-summary.ts";
2
+
3
+ const SEV_RANK: Record<FindingSummary["severity"], number> = {
4
+ critical: 5,
5
+ high: 4,
6
+ medium: 3,
7
+ low: 2,
8
+ info: 1,
9
+ };
10
+
11
+ export interface FilterOptions {
12
+ minSeverity?: FindingSummary["severity"];
13
+ detectors?: string[];
14
+ objectIds?: string[];
15
+ tableIds?: string[];
16
+ files?: string[]; // matches by suffix
17
+ limit?: number;
18
+ }
19
+
20
+ export function filterFindings(findings: FindingSummary[], opts: FilterOptions): FindingSummary[] {
21
+ let out = findings;
22
+ if (opts.minSeverity !== undefined) {
23
+ const min = SEV_RANK[opts.minSeverity];
24
+ out = out.filter((f) => SEV_RANK[f.severity] >= min);
25
+ }
26
+ if (opts.detectors && opts.detectors.length > 0) {
27
+ const allow = new Set(opts.detectors);
28
+ out = out.filter((f) => allow.has(f.detector));
29
+ }
30
+ if (opts.objectIds && opts.objectIds.length > 0) {
31
+ const allow = new Set(opts.objectIds);
32
+ out = out.filter((f) => f.affectedObjects.some((o) => allow.has(o)));
33
+ }
34
+ if (opts.tableIds && opts.tableIds.length > 0) {
35
+ const allow = new Set(opts.tableIds);
36
+ out = out.filter((f) => f.affectedTables.some((t) => allow.has(t)));
37
+ }
38
+ if (opts.files && opts.files.length > 0) {
39
+ const suffixes = opts.files;
40
+ out = out.filter((f) => suffixes.some((s) => f.primaryLocation.file.endsWith(s)));
41
+ }
42
+ if (opts.limit !== undefined) out = out.slice(0, opts.limit);
43
+ return out;
44
+ }
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ObjectDecl, Routine } from "../model/entities.ts";
3
+ import type { Finding } from "../model/finding.ts";
4
+ import type { ObjectId, RoutineId } from "../model/ids.ts";
5
+ import type { SemanticModel } from "../model/model.ts";
6
+
7
+ interface FingerprintIndex {
8
+ routinesById: Map<RoutineId, Routine>;
9
+ objectsById: Map<ObjectId, ObjectDecl>;
10
+ }
11
+
12
+ /**
13
+ * Per-model id indexes, memoized by model identity. `fingerprintOf` is called once per finding
14
+ * by every detector (and the policy engine) — tens of thousands of calls per run on a large
15
+ * workspace. Rebuilding `new Map(model.routines.map(...))` (100k+ entries) on every call was the
16
+ * dominant cost of the detector pass. The model is immutable during detection, so a WeakMap
17
+ * keyed on it builds the indexes once and reuses them; entries are GC'd with the model.
18
+ */
19
+ const indexByModel = new WeakMap<SemanticModel, FingerprintIndex>();
20
+
21
+ function indexFor(model: SemanticModel): FingerprintIndex {
22
+ let idx = indexByModel.get(model);
23
+ if (idx === undefined) {
24
+ idx = {
25
+ routinesById: new Map(model.routines.map((r) => [r.id, r])),
26
+ objectsById: new Map(model.objects.map((o) => [o.id, o])),
27
+ };
28
+ indexByModel.set(model, idx);
29
+ }
30
+ return idx;
31
+ }
32
+
33
+ /**
34
+ * Compute a stable edit-survival key for a finding. Designed so adding blank lines,
35
+ * renaming files, or rebuilding from a different workspace root yields the same key
36
+ * for the same logical finding. Excludes line/column numbers and any field embedding
37
+ * them (e.g. operationId).
38
+ *
39
+ * Components (joined with `|`, sha256, first 16 hex chars):
40
+ * detector | objectType/objectNumber | routineName | affectedTables.join(",") | rootCauseKey
41
+ */
42
+ export function fingerprintOf(finding: Finding, model: SemanticModel): string {
43
+ const { routinesById, objectsById } = indexFor(model);
44
+ const routine = routinesById.get(finding.primaryLocation.enclosingRoutineId);
45
+ const obj = routine ? objectsById.get(routine.objectId) : undefined;
46
+ const parts = [
47
+ finding.detector,
48
+ obj ? `${obj.objectType}/${obj.objectNumber}` : "",
49
+ routine?.name ?? "",
50
+ finding.affectedTables.join(","),
51
+ finding.rootCauseKey,
52
+ ];
53
+ return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
54
+ }
@@ -0,0 +1,41 @@
1
+ import type { FindingSummary } from "./finding-summary.ts";
2
+
3
+ export type GroupBy = "object" | "routine" | "table" | "detector" | "file";
4
+
5
+ export interface FindingGroup {
6
+ key: string;
7
+ label?: string;
8
+ findings: FindingSummary[];
9
+ }
10
+
11
+ function keyFor(f: FindingSummary, by: GroupBy): string {
12
+ switch (by) {
13
+ case "object":
14
+ return f.primaryLocation.objectId ?? "(unknown)";
15
+ case "routine":
16
+ return f.primaryLocation.routineId ?? "(unknown)";
17
+ case "table":
18
+ return f.affectedTables[0] ?? "(none)";
19
+ case "detector":
20
+ return f.detector;
21
+ case "file":
22
+ return f.primaryLocation.file;
23
+ }
24
+ }
25
+
26
+ export function groupFindings(findings: FindingSummary[], by: GroupBy): FindingGroup[] {
27
+ const map = new Map<string, FindingSummary[]>();
28
+ for (const f of findings) {
29
+ const k = keyFor(f, by);
30
+ const list = map.get(k);
31
+ if (list) list.push(f);
32
+ else map.set(k, [f]);
33
+ }
34
+ const groups: FindingGroup[] = [...map.entries()].map(([key, list]) => ({
35
+ key,
36
+ findings: list,
37
+ }));
38
+ // largest first, then alphabetical for determinism
39
+ groups.sort((a, b) => b.findings.length - a.findings.length || (a.key < b.key ? -1 : 1));
40
+ return groups;
41
+ }
@@ -0,0 +1,110 @@
1
+ import type { ObjectDecl, Routine } from "../model/entities.ts";
2
+ import type { Finding, FixOption } from "../model/finding.ts";
3
+ import type { SourceAnchor } from "../model/identity.ts";
4
+ import type { ObjectId, RoutineId } from "../model/ids.ts";
5
+ import type { SemanticModel } from "../model/model.ts";
6
+
7
+ export interface FindingLocation {
8
+ file: string; // sourceUnitId, e.g. "ws:Al/Codeunit/Foo.Codeunit.al"
9
+ line: number; // 1-based, for display
10
+ column: number; // 1-based, for display
11
+ objectId?: string;
12
+ objectName?: string;
13
+ routineId?: string;
14
+ routineName?: string;
15
+ }
16
+
17
+ export interface FindingSummary {
18
+ id: string;
19
+ fingerprint: string; // edit-survival key — Phase 4 fills in real fingerprint
20
+ detector: string;
21
+ title: string;
22
+ rootCause: string;
23
+ severity: Finding["severity"];
24
+ confidence: { level: Finding["confidence"]["level"]; cappedBy?: string[] };
25
+ primaryLocation: FindingLocation;
26
+ terminalLocation?: FindingLocation;
27
+ affectedObjects: string[];
28
+ affectedTables: string[];
29
+ fixHint?: FixOption;
30
+ /**
31
+ * How many reaching paths the underlying Finding carries. Undefined / 1 for
32
+ * the typical case (single evidencePath). > 1 means the detector (D1, D2)
33
+ * collapsed multiple in-loop ancestor traces into one canonical Finding plus
34
+ * additionalPaths — surfaced here so terminal/SARIF can render
35
+ * "also reached from N other paths". Consumers should treat undefined as 1.
36
+ */
37
+ pathCount?: number;
38
+ }
39
+
40
+ interface ProjectionIndex {
41
+ objectsById: Map<ObjectId, ObjectDecl>;
42
+ routinesById: Map<RoutineId, Routine>;
43
+ }
44
+
45
+ /**
46
+ * Per-model id indexes, memoized by model identity. `projectFinding` is called once per finding
47
+ * by every CLI/SARIF/MCP output path; on a large workspace with thousands of findings, rebuilding
48
+ * the 100k+ routine/object maps per call dominated output time. The model is immutable at
49
+ * projection time, so a WeakMap keyed on it builds the indexes once and reuses them.
50
+ */
51
+ const indexByModel = new WeakMap<SemanticModel, ProjectionIndex>();
52
+
53
+ function indexFor(model: SemanticModel): ProjectionIndex {
54
+ let idx = indexByModel.get(model);
55
+ if (idx === undefined) {
56
+ idx = {
57
+ objectsById: new Map(model.objects.map((o) => [o.id, o])),
58
+ routinesById: new Map(model.routines.map((r) => [r.id, r])),
59
+ };
60
+ indexByModel.set(model, idx);
61
+ }
62
+ return idx;
63
+ }
64
+
65
+ /** Project a Finding into a compact summary. Used by every CLI/SARIF/MCP output. */
66
+ export function projectFinding(finding: Finding, model: SemanticModel): FindingSummary {
67
+ const { objectsById, routinesById } = indexFor(model);
68
+
69
+ const toLocation = (anchor: SourceAnchor): FindingLocation => {
70
+ const routine = routinesById.get(anchor.enclosingRoutineId);
71
+ const object = routine ? objectsById.get(routine.objectId) : undefined;
72
+ return {
73
+ file: anchor.sourceUnitId,
74
+ line: anchor.range.startLine + 1,
75
+ column: anchor.range.startColumn + 1,
76
+ objectId: object?.id,
77
+ objectName: object?.name,
78
+ routineId: routine?.id,
79
+ routineName: routine?.name,
80
+ };
81
+ };
82
+
83
+ const primary =
84
+ finding.actionableAnchor !== undefined
85
+ ? toLocation(finding.actionableAnchor)
86
+ : toLocation(finding.primaryLocation);
87
+ const terminal =
88
+ finding.actionableAnchor !== undefined
89
+ ? toLocation(finding.primaryLocation) // when actionable differs, keep original as terminal
90
+ : undefined;
91
+
92
+ return {
93
+ id: finding.id,
94
+ fingerprint: finding.fingerprint ?? finding.id, // Phase 4 fills in real fingerprint
95
+ detector: finding.detector,
96
+ title: finding.title,
97
+ rootCause: finding.rootCause,
98
+ severity: finding.severity,
99
+ confidence: {
100
+ level: finding.confidence.level,
101
+ cappedBy: finding.confidence.cappedBy,
102
+ },
103
+ primaryLocation: primary,
104
+ terminalLocation: terminal,
105
+ affectedObjects: finding.affectedObjects,
106
+ affectedTables: finding.affectedTables,
107
+ fixHint: finding.fixOptions[0],
108
+ pathCount: 1 + (finding.additionalPaths?.length ?? 0),
109
+ };
110
+ }
@@ -0,0 +1,105 @@
1
+ import { compareStrings } from "../engine/uncertainty-util.ts";
2
+ import type { FindingLocation, FindingSummary } from "./finding-summary.ts";
3
+
4
+ /**
5
+ * Inter-detector rollup. Multiple detectors firing at the same source location
6
+ * on the same affected tables usually mean ONE underlying problem viewed from
7
+ * different angles — e.g. D1 (db-op-in-loop) + D5 (set-based opportunity) +
8
+ * D10 (self-modifying loop) all fire on a single `repeat … Modify … until
9
+ * Next() = 0`. The user fixes one thing and all three findings disappear.
10
+ *
11
+ * Grouping is purely presentational. Each contributing Finding stays
12
+ * untouched in the underlying model — baselines, SARIF, and MCP
13
+ * `list_findings` still see per-detector identity. The rollup is what a
14
+ * human (or LLM tool) reads when they want the consolidated view.
15
+ *
16
+ * Group key: `(file, line, column, sortedAffectedTables.join(","))`.
17
+ * - Strict enough that two findings on the same line but DIFFERENT
18
+ * tables stay separate (e.g. two record-modifying loops side-by-side).
19
+ * - Loose enough to catch the D1+D5+D10 case where the file/line/column
20
+ * match exactly and affectedTables share the modified table.
21
+ *
22
+ * A group with exactly one contributor is returned as `kind: "single"`.
23
+ * Two or more share a `kind: "rolled"` entry with `contributors` sorted by
24
+ * severity descending (ties broken by detector id ascending so terminal
25
+ * output stays deterministic).
26
+ */
27
+ const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1 } as const;
28
+ type Severity = keyof typeof SEV_RANK;
29
+
30
+ export type RolledOrSingle =
31
+ | { kind: "single"; finding: FindingSummary }
32
+ | {
33
+ kind: "rolled";
34
+ primaryLocation: FindingLocation;
35
+ affectedTables: string[];
36
+ /** Max severity across contributors — drives sort order in output. */
37
+ severity: Severity;
38
+ /** Sorted: highest-severity first, then by detector id ascending for determinism. */
39
+ contributors: FindingSummary[];
40
+ };
41
+
42
+ /** Build the grouping key. Defined as a constant so callers reading the
43
+ * code understand what counts as "the same problem". */
44
+ function rollupKey(f: FindingSummary): string {
45
+ const p = f.primaryLocation;
46
+ const tables = [...f.affectedTables].sort().join(",");
47
+ return `${p.file}|${p.line}|${p.column}|${tables}`;
48
+ }
49
+
50
+ /**
51
+ * Group findings by `(file, line, column, sorted-tables)`. Returns the
52
+ * groups in MAX-SEVERITY-DESCENDING order with detector-id ascending as the
53
+ * tiebreaker, so callers can render them top-to-bottom without re-sorting.
54
+ *
55
+ * Singleton groups pass through as `{ kind: "single", finding }`. The output
56
+ * length is the number of distinct rollup keys present — typically less
57
+ * than the input length when inter-detector overlap exists.
58
+ */
59
+ export function rollupFindings(findings: FindingSummary[]): RolledOrSingle[] {
60
+ const groups = new Map<string, FindingSummary[]>();
61
+ for (const f of findings) {
62
+ const k = rollupKey(f);
63
+ const list = groups.get(k);
64
+ if (list === undefined) groups.set(k, [f]);
65
+ else list.push(f);
66
+ }
67
+ const out: RolledOrSingle[] = [];
68
+ for (const list of groups.values()) {
69
+ if (list.length === 0) continue;
70
+ const first = list[0];
71
+ if (first === undefined) continue; // type-narrowing
72
+ if (list.length === 1) {
73
+ out.push({ kind: "single", finding: first });
74
+ continue;
75
+ }
76
+ // Multi-contributor rollup. Sort contributors deterministically.
77
+ const sorted = [...list].sort((a, b) => {
78
+ const r = SEV_RANK[b.severity] - SEV_RANK[a.severity];
79
+ if (r !== 0) return r;
80
+ return compareStrings(a.detector, b.detector);
81
+ });
82
+ const canonical = sorted[0];
83
+ if (canonical === undefined) continue;
84
+ out.push({
85
+ kind: "rolled",
86
+ primaryLocation: canonical.primaryLocation,
87
+ affectedTables: canonical.affectedTables,
88
+ severity: canonical.severity,
89
+ contributors: sorted,
90
+ });
91
+ }
92
+ // Order rollups + singles together: max severity first, then file/line for stability.
93
+ out.sort((a, b) => {
94
+ const sevA = a.kind === "rolled" ? a.severity : a.finding.severity;
95
+ const sevB = b.kind === "rolled" ? b.severity : b.finding.severity;
96
+ const r = SEV_RANK[sevB] - SEV_RANK[sevA];
97
+ if (r !== 0) return r;
98
+ const locA = a.kind === "rolled" ? a.primaryLocation : a.finding.primaryLocation;
99
+ const locB = b.kind === "rolled" ? b.primaryLocation : b.finding.primaryLocation;
100
+ if (locA.file !== locB.file) return compareStrings(locA.file, locB.file);
101
+ if (locA.line !== locB.line) return locA.line - locB.line;
102
+ return locA.column - locB.column;
103
+ });
104
+ return out;
105
+ }
@@ -0,0 +1,88 @@
1
+ import { sha256Hex, sha256OfStrings } from "../hash.ts";
2
+ import type { AppIdentity, ModelIdentity } from "../model/identity.ts";
3
+ import type { ExternalSourceProvider } from "./external.ts";
4
+ import type { ProviderDiagnostic, SourceUnit } from "./types.ts";
5
+ import { WorkspaceProvider } from "./workspace.ts";
6
+
7
+ /** al-sem schema/version constants. Bump SCHEMA_VERSION when the serialized model changes. */
8
+ export const SCHEMA_VERSION = "1";
9
+ export const ANALYZER_VERSION = "0.0.1";
10
+ export const GRAMMAR_VERSION = "tree-sitter-al-v2.5.2-native";
11
+ export const SYMBOL_READER_VERSION = "1";
12
+
13
+ export interface DiscoverOptions {
14
+ workspaceRoot: string;
15
+ alpackagesDir?: string;
16
+ externalProvider?: ExternalSourceProvider;
17
+ }
18
+
19
+ export interface DiscoverResult {
20
+ units: SourceUnit[];
21
+ identity: ModelIdentity;
22
+ modelInstanceId: string;
23
+ diagnostics: ProviderDiagnostic[];
24
+ }
25
+
26
+ /** Merge AppIdentity records by appGuid; first occurrence wins for source-bearing fields. */
27
+ function mergeApps(lists: AppIdentity[][]): AppIdentity[] {
28
+ const byGuid = new Map<string, AppIdentity>();
29
+ for (const list of lists) {
30
+ for (const app of list) {
31
+ if (!byGuid.has(app.appGuid)) byGuid.set(app.appGuid, app);
32
+ }
33
+ }
34
+ return [...byGuid.values()];
35
+ }
36
+
37
+ /**
38
+ * Run all source providers, merge results, and build the ModelIdentity. The
39
+ * modelInstanceId is derived from the discovered apps + unit ids so it is stable for
40
+ * identical inputs and changes when inputs change.
41
+ *
42
+ * Precondition: callers must pass an already-normalized absolute `workspaceRoot` —
43
+ * `workspace.rootHash` hashes the raw path, so non-canonical paths (trailing slash,
44
+ * symlink) would produce a different hash. (Task 19's `analyzeWorkspace` normalizes
45
+ * before calling.)
46
+ */
47
+ export async function discoverSources(options: DiscoverOptions): Promise<DiscoverResult> {
48
+ const { workspaceRoot, externalProvider } = options;
49
+ const diagnostics: ProviderDiagnostic[] = [];
50
+
51
+ const wsResult = await new WorkspaceProvider().collect(workspaceRoot);
52
+ diagnostics.push(...wsResult.diagnostics);
53
+
54
+ const extResult = externalProvider
55
+ ? await externalProvider.collect(workspaceRoot)
56
+ : { units: [], apps: [], diagnostics: [] };
57
+ diagnostics.push(...extResult.diagnostics);
58
+
59
+ const units = [...wsResult.units, ...extResult.units];
60
+ const apps = mergeApps([wsResult.apps, extResult.apps]);
61
+
62
+ // Workspace app is the primary app.
63
+ // undefined when WorkspaceProvider could not read app.json — a diagnostic was already pushed
64
+ const primaryApp = wsResult.apps[0];
65
+
66
+ const dependencyGraphHash = sha256OfStrings(apps.map((a) => `${a.appGuid}@${a.version}`).sort());
67
+
68
+ const modelInstanceId = sha256OfStrings([
69
+ dependencyGraphHash,
70
+ ...units.map((u) => u.id).sort(),
71
+ ]).slice(0, 16);
72
+
73
+ const identity: ModelIdentity = {
74
+ schemaVersion: SCHEMA_VERSION,
75
+ analyzerVersion: ANALYZER_VERSION,
76
+ grammarVersion: GRAMMAR_VERSION,
77
+ symbolReaderVersion: SYMBOL_READER_VERSION,
78
+ createdAt: new Date(0).toISOString(), // fixed for determinism; callers may override
79
+ workspace: {
80
+ rootHash: sha256Hex(workspaceRoot),
81
+ },
82
+ primaryApp,
83
+ apps,
84
+ dependencyGraphHash,
85
+ };
86
+
87
+ return { units, identity, modelInstanceId, diagnostics };
88
+ }
@@ -0,0 +1,46 @@
1
+ import type { AppIdentity } from "../model/identity.ts";
2
+ import type { ProviderResult, SourceProvider, SourceUnit } from "./types.ts";
3
+
4
+ export interface ExternalSourceInjection {
5
+ appGuid: string;
6
+ publisher?: string;
7
+ name?: string;
8
+ version?: string;
9
+ /** Relative path -> .al content. */
10
+ files: Record<string, string>;
11
+ }
12
+
13
+ /**
14
+ * Seam for external AL source (e.g. Microsoft base-app source from a downloaded history
15
+ * repo). Phase 1 is a stub: no downloader is built. Source can be injected directly,
16
+ * which is how a future downloader — or a test — feeds it in without changing callers.
17
+ */
18
+ export class ExternalSourceProvider implements SourceProvider {
19
+ readonly name = "external-source" as const;
20
+
21
+ constructor(private readonly injection?: ExternalSourceInjection) {}
22
+
23
+ async collect(_rootPath: string): Promise<ProviderResult> {
24
+ if (!this.injection) {
25
+ return { units: [], apps: [], diagnostics: [] };
26
+ }
27
+
28
+ const { appGuid, publisher, name, version, files } = this.injection;
29
+ const units: SourceUnit[] = Object.entries(files).map(([relativePath, content]) => ({
30
+ id: `ext:${appGuid}:${relativePath}`,
31
+ kind: "source",
32
+ appGuid,
33
+ relativePath,
34
+ content,
35
+ sourceProvider: "external-source",
36
+ }));
37
+ const identity: AppIdentity = {
38
+ appGuid,
39
+ publisher: publisher ?? "unknown",
40
+ name: name ?? "unknown",
41
+ version: version ?? "0.0.0.0",
42
+ sourceKind: "external-source",
43
+ };
44
+ return { units, apps: [identity], diagnostics: [] };
45
+ }
46
+ }
@@ -0,0 +1,36 @@
1
+ import type { AppIdentity } from "../model/identity.ts";
2
+
3
+ /**
4
+ * One unit of AL input. `kind: "source"` carries `.al` text to parse and index.
5
+ * `kind: "symbol-only"` marks a dependency whose bodies are opaque (no source available).
6
+ */
7
+ export interface SourceUnit {
8
+ id: string; // stable per analysis run, e.g. "ws:<relpath>" or "app:<guid>:<relpath>"
9
+ kind: "source" | "symbol-only";
10
+ appGuid: string;
11
+ relativePath: string;
12
+ absolutePath?: string; // present for workspace files
13
+ content?: string; // present when kind === "source"
14
+ sourceProvider: "workspace" | "app-package" | "external-source";
15
+ /** "primary" (workspace, under analysis) or "dependency" (context only). Absent ⇒ primary. */
16
+ analysisRole?: "primary" | "dependency";
17
+ }
18
+
19
+ export interface ProviderResult {
20
+ units: SourceUnit[];
21
+ apps: AppIdentity[];
22
+ diagnostics: ProviderDiagnostic[];
23
+ }
24
+
25
+ export interface ProviderDiagnostic {
26
+ severity: "error" | "warning" | "info";
27
+ message: string;
28
+ sourceRef?: string;
29
+ }
30
+
31
+ /** A source of AL input. Each implementation knows one origin of AL. */
32
+ export interface SourceProvider {
33
+ readonly name: "workspace" | "app-package" | "external-source";
34
+ /** Enumerate all source units this provider can offer for the given root. */
35
+ collect(rootPath: string): Promise<ProviderResult>;
36
+ }