@ecoma-io/archkeep 0.13.0

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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The one place a path derived from workspace content is proven to stay
3
+ * inside the workspace it came from.
4
+ *
5
+ * Two invariants share one mechanism, on both sides of the read/write
6
+ * boundary:
7
+ *
8
+ * - **Reads.** A tracked path is `join(root, path)` for a path `git ls-files`
9
+ * returned, so the STRING is inside the tree by construction — but the OS
10
+ * resolves symlinks before the bytes arrive, and a tracked symlink (mode
11
+ * 120000) whose target lives outside the tree hands the reader bytes the
12
+ * workspace never committed, judged as the workspace's own source. That is
13
+ * the read-side escape: outside code read into the verdict, reported clean.
14
+ * - **Writes.** `--output` and `history --capture` both write a path the
15
+ * caller named. A workspace-controlled symlink in an INTERMEDIATE directory
16
+ * component redirects the write outside the tree — the bytes land
17
+ * somewhere other than the path named, with the run reporting success.
18
+ *
19
+ * The shared policy: the realpath of the deepest existing ancestor of the
20
+ * target must resolve inside the realpath of the workspace root. Where
21
+ * containment cannot be proven, the operation refuses — it never silently
22
+ * reads the outside bytes or writes them elsewhere.
23
+ *
24
+ * Writes carry one additional rule, the determinism half of the escape: every
25
+ * existing path component below the root must not itself be a symlink. A
26
+ * `sub -> .` self-loop resolves INSIDE the workspace, so the realpath check
27
+ * passes — but the write lands at the workspace root rather than under
28
+ * `sub/`, a different location than the user named. Any symlinked intermediate
29
+ * component makes the write's landing spot a function of the tree, not of the
30
+ * name, so it is refused for writes (and only for writes: an internal symlink
31
+ * is a legitimate tracked layout for READS, and stays allowed there).
32
+ *
33
+ * The workspace root is taken as the string `findWorkspaceRoot` returned,
34
+ * not its realpath: a checkout reached through a symlinked mount
35
+ * (`/workspaces/team -> /mnt/repos`) is the tool's legitimate view of the
36
+ * tree, and only components BELOW the root string are inspected. A target
37
+ * the caller names OUTSIDE the root string (`--output /tmp/report.json`) is
38
+ * the caller's explicit choice, not a tree-derived path, and is left
39
+ * untouched — the escape this refuses is a tree-controlled symlink
40
+ * redirecting a name that was inside the tree.
41
+ *
42
+ * One contract binds the WRITE call sites: a target is `resolve`d ONCE, and
43
+ * the identical resolved string feeds both the containment decision and the
44
+ * actual write. A raw `..` segment is refused outright here (`containsDotDot`),
45
+ * because `lstat` answers ENOENT for a non-collapsed `..` and the probe would
46
+ * skip a symlink the kernel follows (`sub -> /tmp/out`, `sub/../x`); a caller
47
+ * that forgets the `resolve` gets a loud refusal instead of a silent escape.
48
+ *
49
+ * Out of scope, deliberately: a write target spelled with a case that
50
+ * STRING-differs from the workspace root on a case-INSENSITIVE filesystem
51
+ * (macOS) reads as "explicitly outside" (`within` compares strings, and
52
+ * `relative()` is case-insensitive only on Windows). The realpath containment
53
+ * check — which would catch the escape through that path — runs only for
54
+ * string-inside targets. Closing it would need to run the realpath probe for
55
+ * every write, which would refuse the legitimate explicitly-outside target
56
+ * (`--output /tmp/report.json`) that the string test deliberately exempts.
57
+ * The corner requires a hand-typed case-mismatched flag on macOS; it is named
58
+ * here rather than silently inherited.
59
+ */
60
+ import { lstatSync as defaultLstat, realpathSync as defaultRealpath } from "node:fs";
61
+ import { dirname, isAbsolute, relative, sep } from "node:path";
62
+
63
+ /**
64
+ * Whether a path still carries a raw `..` segment. The containment probe
65
+ * walks the path with `lstat`, which answers ENOENT for a non-collapsed
66
+ * `..` — so a `..` that crosses a symlink (`sub -> /tmp/out`, target
67
+ * `sub/../x`) lexically reads as contained while the kernel would first
68
+ * follow `sub` out of the tree. A path with a `..` segment is therefore
69
+ * REFUSED outright; a caller must `resolve()` it first and then hand the
70
+ * SAME resolved string to both the containment check and the actual write.
71
+ *
72
+ * @param {string} absPath
73
+ * @returns {boolean}
74
+ */
75
+ function containsDotDot(absPath) {
76
+ return absPath.split(/[\\/]/).includes("..");
77
+ }
78
+
79
+ /**
80
+ * Whether `realPath` resolves outside `rootReal`, compared as path-component
81
+ * sequences rather than via `relative()`'s string form. `relative()` yields
82
+ * `..\…` on Windows, so testing the string `"../"` would let a same-drive
83
+ * sibling (`C:\ws\x.go` vs `C:\out\x.go`) read as contained; comparing
84
+ * components makes both separator families and differing drive letters plain
85
+ * divergences. Inputs are `realpathSync` outputs, which are canonical
86
+ * (no `..`, no trailing separator) by contract.
87
+ *
88
+ * @param {string} rootReal Realpath of the workspace root.
89
+ * @param {string} realPath Realpath of a deeper existing ancestor.
90
+ * @returns {boolean}
91
+ */
92
+ export function pathEscapes(rootReal, realPath) {
93
+ const rootParts = rootReal.split(/[\\/]/).filter((part) => part !== "");
94
+ const realParts = realPath.split(/[\\/]/).filter((part) => part !== "");
95
+ for (let i = 0; i < rootParts.length; i++) {
96
+ if (rootParts[i] !== realParts[i]) return true;
97
+ }
98
+ return realParts.length < rootParts.length;
99
+ }
100
+
101
+ /**
102
+ * Whether `absPath` is strictly below `root`, string-level (no symlink
103
+ * resolution). The platform separator is honoured explicitly — `relative()`
104
+ * yields `..\…` on Windows, so testing `"../"` alone would let a same-drive
105
+ * sibling (`C:\ws\x.go` vs `C:\out\x.go`) read as contained.
106
+ *
107
+ * @param {string} root
108
+ * @param {string} absPath
109
+ * @returns {boolean}
110
+ */
111
+ function within(root, absPath) {
112
+ const rel = relative(root, absPath);
113
+ return (
114
+ rel !== "" &&
115
+ rel !== ".." &&
116
+ !rel.startsWith(`..${sep}`) &&
117
+ !rel.startsWith(".." + "/") &&
118
+ !isAbsolute(rel)
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The deepest ancestor of `absPath` that exists (lstat-able), or `null` when
124
+ * no ancestor exists. `absPath` itself is tried first, then each parent — the
125
+ * common `--output` target is a file that does not exist yet, whose parent
126
+ * directory does.
127
+ *
128
+ * @param {string} absPath
129
+ * @param {(path: string) => {isSymbolicLink: () => boolean}} lstat
130
+ * @returns {string|null}
131
+ */
132
+ function deepestExistingAncestor(absPath, lstat) {
133
+ let probe = absPath;
134
+ for (;;) {
135
+ try {
136
+ lstat(probe);
137
+ return probe;
138
+ } catch {
139
+ const parent = dirname(probe);
140
+ if (parent === probe) return null;
141
+ probe = parent;
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The containment violation for `absPath` against `root`, or `null` when the
148
+ * path is contained.
149
+ *
150
+ * `forWrite: true` applies the write policy (below-root components may not be
151
+ * symlinks); the default is the read policy (realpath containment only, so a
152
+ * tracked internal symlink keeps working).
153
+ *
154
+ * `lstatSync`/`realpathSync` are injectable so the pure decision can be
155
+ * tested without a filesystem — the same seam `../governance/adr-registry.mjs`
156
+ * passes into this helper from its `loadAdrRegistry` io objects.
157
+ *
158
+ * @param {string} root Absolute workspace root, as `findWorkspaceRoot`
159
+ * returned it.
160
+ * @param {string} absPath Absolute path to check.
161
+ * @param {{forWrite?: boolean, lstatSync?: (path: string) => {isSymbolicLink: () => boolean},
162
+ * realpathSync?: (path: string) => string}} [io]
163
+ * @returns {string|null} A reason the path is not contained, or `null`.
164
+ */
165
+ export function containmentViolation(
166
+ root,
167
+ absPath,
168
+ {
169
+ forWrite = false,
170
+ lstatSync: lstat = defaultLstat,
171
+ realpathSync: realpath = defaultRealpath,
172
+ } = {},
173
+ ) {
174
+ // A raw `..` is refused outright: the probe cannot prove where the kernel
175
+ // would land (see `containsDotDot`), so passing a non-`resolve`d path in
176
+ // here is a caller bug made loud. The write call sites resolve first and
177
+ // hand the identical string to both check and write; the read call sites
178
+ // receive tracker paths (`git ls-files`, LSP index), which resolve lexically.
179
+ if (containsDotDot(absPath)) {
180
+ return `'${absPath}' contains a '..' segment — resolve the path first so the containment check and the actual write land at the same place`;
181
+ }
182
+ if (forWrite && !within(root, absPath)) return null;
183
+ const ancestor = deepestExistingAncestor(absPath, lstat);
184
+ if (ancestor !== null) {
185
+ let ancestorReal;
186
+ try {
187
+ ancestorReal = realpath(ancestor);
188
+ } catch {
189
+ ancestorReal = null;
190
+ }
191
+ if (ancestorReal !== null) {
192
+ let rootReal;
193
+ try {
194
+ rootReal = realpath(root);
195
+ } catch {
196
+ rootReal = root;
197
+ }
198
+ if (pathEscapes(rootReal, ancestorReal)) {
199
+ return (
200
+ `'${ancestor}' resolves to '${ancestorReal}', outside the workspace root '${rootReal}' — ` +
201
+ "a symlink in this tree must not lead out of it"
202
+ );
203
+ }
204
+ }
205
+ }
206
+ if (forWrite) {
207
+ const below = absPath
208
+ .slice(root.length)
209
+ .split(sep)
210
+ .filter((part) => part !== "");
211
+ // The final name itself is not walked: `renameSync` replaces a symlink at
212
+ // the destination as a directory-entry swap and never dereferences it
213
+ // (`../cli.mjs`'s `writeOutputReport` docstring owns that half).
214
+ let current = root;
215
+ for (const part of below.slice(0, -1)) {
216
+ current = `${current}${sep}${part}`;
217
+ let stat;
218
+ try {
219
+ stat = lstat(current);
220
+ } catch {
221
+ // A component that does not exist is not a symlink; the write itself
222
+ // will fail loudly on it (`ENOENT`), so this is not a silent path.
223
+ continue;
224
+ }
225
+ if (stat.isSymbolicLink()) {
226
+ return (
227
+ `'${current}' is a symlink — writing through it would land somewhere other than the ` +
228
+ `path '${absPath}' names, so the write is refused`
229
+ );
230
+ }
231
+ }
232
+ }
233
+ return null;
234
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The evidence bundle — the only thing a custom rule ever receives, and the
3
+ * exact bytes it receives it as.
4
+ *
5
+ * A custom rule is a pure function from evidence to verdict
6
+ * (`../../../../docs/adr/0002-custom-rules-one-contract.md`). This module owns
7
+ * the "in" half: it collects the facts the engine already observed into one
8
+ * versioned document and hands the caller its canonical UTF-8 serialization.
9
+ * Nothing here reads a file, asks a provider, or judges anything — the caller
10
+ * supplies observed facts, the same posture `../rules/README.md` states for
11
+ * the built-in rules ("reads records, never files").
12
+ *
13
+ * ## A missing kind throws; it never serializes as empty
14
+ *
15
+ * Every one of the four kinds is required, and a caller that omits one — or
16
+ * supplies something that is not the shape the kind promises — gets a thrown
17
+ * error naming it. The tempting alternative is to write `imports: []` and move
18
+ * on, and that is the silent direction the repository is built against
19
+ * (`../../../../AGENTS.md`, "An empty result is a claim, not a shrug"): a rule
20
+ * handed an empty import list cannot tell "this workspace writes no imports"
21
+ * from "the pipeline never collected any", so it answers `pass` for a reason
22
+ * nobody earned. A throw is a bug in the caller that composed the bundle, not
23
+ * a fact about the workspace, which is why it is an exception rather than a
24
+ * violation list — the same posture `../report/evidence.mjs`'s `buildDecision`
25
+ * takes when a verdict and its counts disagree.
26
+ *
27
+ * ## Byte-determinism, and the two orders that are NOT normalized here
28
+ *
29
+ * The bundle is serialized by `../canonical.mjs` — the one canonicalizer this
30
+ * package owns, so a fingerprint and a rule's input can never disagree about
31
+ * what a document is. It sorts object keys at every depth and deliberately
32
+ * leaves array order alone, so this module sorts the two arrays whose incoming
33
+ * order is an accident of who produced them: projects (a provider's discovery
34
+ * order) and edges (a graph read). Both are sorted with plain `<`.
35
+ *
36
+ * Two arrays are deliberately left in the caller's order:
37
+ *
38
+ * - **`imports`** — source order is part of the analysis contract
39
+ * (`../analysis/contract.md`: "every import site, in source order"), so
40
+ * re-sorting would destroy a fact rather than normalize a nuisance. It is
41
+ * still deterministic upstream: the file list comes from `git ls-files` and
42
+ * each analyzer walks a file top to bottom.
43
+ * - **`policy.depConstraints`** — row order is semantic for a boundary policy
44
+ * (`../canonical.mjs`'s header argues exactly this for fingerprints), and a
45
+ * rule reading the policy has to see the order the workspace wrote.
46
+ *
47
+ * ## What the bundle carries of a policy, and the one rename
48
+ *
49
+ * `../config.mjs` returns a loaded policy as `{ depConstraints, options, … }`
50
+ * — `options` is that module's name for the eight
51
+ * `@nx/enforce-module-boundaries` settings. The bundle spells it
52
+ * `moduleBoundaryOptions`, which is what the workspace itself wrote in its
53
+ * policy file and therefore the name a rule author is reading. The rename
54
+ * happens here, once, and the two keys the contract pins are the only two the
55
+ * bundle carries: `suppressions` and `fitness` ride on the same loaded object
56
+ * and are ignored, because acceptance is not a rule's business
57
+ * (`../governance/waiver.mjs` owns it) and a rule that could see waivers could
58
+ * launder them into its own verdict.
59
+ */
60
+
61
+ import { canonicalizeJson } from "../canonical.mjs";
62
+ import { describeValue, isNonEmptyString, isPlainObject } from "./values.mjs";
63
+
64
+ /** The evidence contract version this engine speaks and every bundle states. */
65
+ export const EVIDENCE_CONTRACT = 1;
66
+
67
+ /**
68
+ * The four evidence kinds contract 1 carries, in the order the schema lists
69
+ * them. This is the roster `needs` is checked against (`./host.mjs`), so it
70
+ * lives here — beside the code that produces the kinds — rather than being
71
+ * restated by the host that consumes it.
72
+ */
73
+ export const EVIDENCE_KINDS = Object.freeze(["model", "graph", "imports", "policy"]);
74
+
75
+ /**
76
+ * @param {string} detail
77
+ * @returns {never}
78
+ */
79
+ function refuse(detail) {
80
+ throw new Error(`archkeep: refusing to build an evidence bundle — ${detail}`);
81
+ }
82
+
83
+ /**
84
+ * @typedef {object} EvidenceRule
85
+ * @property {string} name The declared rule name, as the policy row spells it.
86
+ * @property {Record<string, any>} [params] The declared parameters, if any.
87
+ */
88
+
89
+ /**
90
+ * @typedef {object} EvidenceBundle
91
+ * @property {number} contract Always `EVIDENCE_CONTRACT`.
92
+ * @property {{name: string, params: Record<string, any>}} rule
93
+ * @property {{projects: Array<{name: string, root: string, tags: string[]}>}} model
94
+ * @property {{edges: Array<Record<string, any>>}} graph
95
+ * @property {Array<Record<string, any>>} imports
96
+ * @property {{depConstraints: object[], moduleBoundaryOptions: Record<string, any>}} policy
97
+ */
98
+
99
+ /**
100
+ * Builds the evidence bundle one rule instance is judged over.
101
+ *
102
+ * `rule` is the declared `customRules` row and is **read, never written**: a
103
+ * row that declares no `params` gets `{}` in the bundle and keeps its own
104
+ * absent field, because the bundle is a view and the policy is the law — a
105
+ * defaulted `params` written back onto the row would make the loaded policy
106
+ * disagree with the file the workspace committed, and every later reader of
107
+ * that row (a report, a fingerprint, a second bundle) would carry the
108
+ * engine's guess as if the workspace had written it.
109
+ *
110
+ * `imports` is a list of `{ site, sourceProject }` pairs rather than bare
111
+ * records: attribution is the workspace layer's answer (`../workspace.mjs` is
112
+ * the only layer allowed to say which files a project owns), so the caller
113
+ * hands it over and this module never re-derives it from a root prefix. Each
114
+ * site is copied through verbatim — every key the analysis contract fixes,
115
+ * untouched — with `sourceProject` added.
116
+ *
117
+ * @param {{
118
+ * rule: EvidenceRule,
119
+ * projects: Array<{name: string, root: string, tags: string[]}>,
120
+ * edges: Array<Record<string, any>>,
121
+ * imports: Array<{site: Record<string, any>, sourceProject: string}>,
122
+ * policy: {depConstraints: object[], options: Record<string, any>}
123
+ * }} observed Every one of the five is required.
124
+ * @returns {EvidenceBundle}
125
+ * @throws {Error} when a kind is absent or malformed — never an empty stand-in.
126
+ */
127
+ export function buildEvidenceBundle(observed) {
128
+ if (!isPlainObject(observed)) {
129
+ refuse(`the observed facts must be an object, got ${describeValue(observed)}`);
130
+ }
131
+ const { rule, projects, edges, imports, policy } = observed;
132
+
133
+ return {
134
+ contract: EVIDENCE_CONTRACT,
135
+ rule: bundleRule(rule),
136
+ model: { projects: bundleProjects(projects) },
137
+ graph: { edges: bundleEdges(edges) },
138
+ imports: bundleImports(imports),
139
+ policy: bundlePolicy(policy),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * The `rule` block: the declared name and the declared parameters, with `{}`
145
+ * standing in for an absent `params` HERE and nowhere else.
146
+ *
147
+ * @param {unknown} rule
148
+ * @returns {{name: string, params: Record<string, any>}}
149
+ */
150
+ function bundleRule(rule) {
151
+ if (!isPlainObject(rule)) refuse(`rule: must be the declared row, got ${describeValue(rule)}`);
152
+ if (!isNonEmptyString(rule.name)) {
153
+ refuse(`rule.name: must be the declared rule name, got ${describeValue(rule.name)}`);
154
+ }
155
+ if (rule.params !== undefined && !isPlainObject(rule.params)) {
156
+ refuse(`rule.params: must be a plain object when declared, got ${describeValue(rule.params)}`);
157
+ }
158
+ return { name: rule.name, params: rule.params === undefined ? {} : rule.params };
159
+ }
160
+
161
+ /**
162
+ * The `model` kind's projects, sorted by name.
163
+ *
164
+ * @param {unknown} projects
165
+ * @returns {Array<{name: string, root: string, tags: string[]}>}
166
+ */
167
+ function bundleProjects(projects) {
168
+ if (!Array.isArray(projects)) {
169
+ refuse(
170
+ `model: projects must be an array — "no projects" and "the model was never read" must not ` +
171
+ `serialize the same way, got ${describeValue(projects)}`,
172
+ );
173
+ }
174
+ const rows = projects.map((project, index) => {
175
+ if (!isPlainObject(project)) {
176
+ refuse(`model: projects[${index}] must be an object, got ${describeValue(project)}`);
177
+ }
178
+ if (!isNonEmptyString(project.name)) {
179
+ refuse(
180
+ `model: projects[${index}].name must be a non-empty string, got ${describeValue(project.name)}`,
181
+ );
182
+ }
183
+ if (typeof project.root !== "string") {
184
+ refuse(`model: projects[${index}].root must be a string, got ${describeValue(project.root)}`);
185
+ }
186
+ if (!Array.isArray(project.tags) || project.tags.some((tag) => typeof tag !== "string")) {
187
+ refuse(
188
+ `model: projects[${index}].tags must be an array of strings — an untagged project declares ` +
189
+ `[], and a project whose tags were never read must not look identical to it, got ` +
190
+ `${describeValue(project.tags)}`,
191
+ );
192
+ }
193
+ return { name: project.name, root: project.root, tags: [...project.tags] };
194
+ });
195
+ return rows.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
196
+ }
197
+
198
+ /**
199
+ * The keys an edge is ordered by, most significant first. `sourceFile` is on
200
+ * the list because a graph adapter that carries one produces several edges
201
+ * between the same pair, and an order that stopped at `type` would leave those
202
+ * in whatever order the adapter emitted them.
203
+ */
204
+ const EDGE_SORT_KEYS = Object.freeze(["source", "target", "type", "sourceFile"]);
205
+
206
+ /**
207
+ * The `graph` kind's edges, sorted by source, target, type, then sourceFile.
208
+ *
209
+ * @param {unknown} edges
210
+ * @returns {Array<Record<string, any>>}
211
+ */
212
+ function bundleEdges(edges) {
213
+ if (!Array.isArray(edges)) {
214
+ refuse(
215
+ `graph: edges must be an array — "no dependencies" and "the graph was never read" must not ` +
216
+ `serialize the same way, got ${describeValue(edges)}`,
217
+ );
218
+ }
219
+ const rows = edges.map((edge, index) => {
220
+ if (!isPlainObject(edge)) {
221
+ refuse(`graph: edges[${index}] must be an object, got ${describeValue(edge)}`);
222
+ }
223
+ for (const key of ["source", "target", "type"]) {
224
+ if (!isNonEmptyString(edge[key])) {
225
+ refuse(
226
+ `graph: edges[${index}].${key} must be a non-empty string, got ${describeValue(edge[key])}`,
227
+ );
228
+ }
229
+ }
230
+ if (edge.sourceFile !== undefined && typeof edge.sourceFile !== "string") {
231
+ refuse(
232
+ `graph: edges[${index}].sourceFile must be a string when present, got ${describeValue(edge.sourceFile)}`,
233
+ );
234
+ }
235
+ return edge.sourceFile === undefined
236
+ ? { source: edge.source, target: edge.target, type: edge.type }
237
+ : {
238
+ source: edge.source,
239
+ target: edge.target,
240
+ type: edge.type,
241
+ sourceFile: edge.sourceFile,
242
+ };
243
+ });
244
+ return rows.sort((left, right) => {
245
+ for (const key of EDGE_SORT_KEYS) {
246
+ // `?? ""` covers the absent `sourceFile` only: plain `<` against
247
+ // `undefined` is false in both directions, which would silently make the
248
+ // comparator claim two different edges are equal.
249
+ const leftValue = left[key] ?? "";
250
+ const rightValue = right[key] ?? "";
251
+ if (leftValue < rightValue) return -1;
252
+ if (leftValue > rightValue) return 1;
253
+ }
254
+ return 0;
255
+ });
256
+ }
257
+
258
+ /**
259
+ * The `imports` kind: every analysis record verbatim, plus its attribution.
260
+ *
261
+ * @param {unknown} imports
262
+ * @returns {Array<Record<string, any>>}
263
+ */
264
+ function bundleImports(imports) {
265
+ if (!Array.isArray(imports)) {
266
+ refuse(
267
+ `imports: must be an array of { site, sourceProject } — "this tree writes no imports" and ` +
268
+ `"nothing was analyzed" must not serialize the same way, got ${describeValue(imports)}`,
269
+ );
270
+ }
271
+ return imports.map((entry, index) => {
272
+ if (!isPlainObject(entry)) {
273
+ refuse(`imports[${index}]: must be { site, sourceProject }, got ${describeValue(entry)}`);
274
+ }
275
+ if (!isPlainObject(entry.site)) {
276
+ refuse(
277
+ `imports[${index}].site: must be an analysis record (../analysis/contract.md), got ` +
278
+ `${describeValue(entry.site)}`,
279
+ );
280
+ }
281
+ if (!isNonEmptyString(entry.sourceProject)) {
282
+ refuse(
283
+ `imports[${index}].sourceProject: must name the project the importing file belongs to, got ` +
284
+ `${describeValue(entry.sourceProject)} — an unattributed import site is a site no rule ` +
285
+ `can place, and dropping it would be the silent direction`,
286
+ );
287
+ }
288
+ // Attribution last: the caller's answer wins over anything a record
289
+ // already carried under that name, so there is one source of it.
290
+ return { ...entry.site, sourceProject: entry.sourceProject };
291
+ });
292
+ }
293
+
294
+ /**
295
+ * The `policy` kind: the two fields the contract pins, from the loaded policy.
296
+ *
297
+ * @param {unknown} policy
298
+ * @returns {{depConstraints: object[], moduleBoundaryOptions: Record<string, any>}}
299
+ */
300
+ function bundlePolicy(policy) {
301
+ if (!isPlainObject(policy)) {
302
+ refuse(
303
+ `policy: must be the loaded policy object (../config.mjs), got ${describeValue(policy)}`,
304
+ );
305
+ }
306
+ if (!Array.isArray(policy.depConstraints)) {
307
+ refuse(
308
+ `policy.depConstraints: must be an array — a policy that declares no constraint rows ` +
309
+ `declares [], got ${describeValue(policy.depConstraints)}`,
310
+ );
311
+ }
312
+ if (!isPlainObject(policy.options)) {
313
+ refuse(
314
+ `policy.options: must be the loaded module-boundary options object — ../config.mjs names ` +
315
+ `them "options" and requires every one of the eight to be stated, got ` +
316
+ `${describeValue(policy.options)}`,
317
+ );
318
+ }
319
+ return {
320
+ depConstraints: [...policy.depConstraints],
321
+ moduleBoundaryOptions: { ...policy.options },
322
+ };
323
+ }
324
+
325
+ /**
326
+ * The bundle's canonical UTF-8 bytes — what the host writes into a rule's
327
+ * linear memory, and the only form a rule ever sees.
328
+ *
329
+ * Bytes rather than a string because that is what crosses the ABI, and
330
+ * producing them here means the length the host allocates and the length it
331
+ * writes are the same number by construction: a caller that measured a
332
+ * string's `.length` instead would under-allocate for every non-ASCII
333
+ * character in a project name or a reason.
334
+ *
335
+ * @param {EvidenceBundle} bundle
336
+ * @returns {Uint8Array}
337
+ */
338
+ export function serializeEvidenceBundle(bundle) {
339
+ return new TextEncoder().encode(canonicalizeJson(bundle));
340
+ }