@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|