@ecoma-io/archkeep 0.14.0 → 0.16.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/README.md +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- package/src/workspace.mjs +115 -23
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change-intent contract — grammar, validation, and loading.
|
|
3
|
+
*
|
|
4
|
+
* A change intent is a PER-CHANGE declaration of the material architectural
|
|
5
|
+
* consequences its author expects one specific edit to produce: which projects
|
|
6
|
+
* appear or disappear, which project-to-project dependencies appear or
|
|
7
|
+
* disappear, and which delta-level constraints the change must hold. It is
|
|
8
|
+
* written beside the work, verified by the `change` command against the actual
|
|
9
|
+
* architectural delta (`./change.mjs`), and then belongs to the pull request
|
|
10
|
+
* as the reviewable answer to "what did this change do to the architecture?".
|
|
11
|
+
*
|
|
12
|
+
* This file mirrors `../architecture-intent/model.mjs`'s split — a pure
|
|
13
|
+
* `(raw) -> string[]` validator, a thin loader that reads, parses, validates
|
|
14
|
+
* and throws one Error naming every violation at once — and deliberately does
|
|
15
|
+
* NOT extend that module: architecture-intent.json is the workspace's
|
|
16
|
+
* long-lived law, judged by `drift`/`check`; a change intent names one
|
|
17
|
+
* transaction's expected consequences and expires with its pull request. One
|
|
18
|
+
* grammar per concept, so neither can grow the other's semantics by accident.
|
|
19
|
+
*
|
|
20
|
+
* Four contracts, each the honest side of the empty-result invariant
|
|
21
|
+
* (`../../../../AGENTS.md` — an empty list must mean "nothing unexpected",
|
|
22
|
+
* and nothing else):
|
|
23
|
+
*
|
|
24
|
+
* - **It is strict JSON, never JSONC**, for the same reason
|
|
25
|
+
* `../architecture-intent/model.mjs` refuses comments: this is a
|
|
26
|
+
* machine-written declaration a verification run consumes, and a comment
|
|
27
|
+
* is a load error, not leniency.
|
|
28
|
+
* - **An unknown key is rejected by name**, never ignored — a typo'd
|
|
29
|
+
* section would silently declare nothing, and a run over a declaration
|
|
30
|
+
* that said nothing must not read as "the change declared nothing
|
|
31
|
+
* material".
|
|
32
|
+
* - **A duplicate declaration is a load error**, not last-one-wins: the
|
|
33
|
+
* contract is a SET of expected facts, and two rows under one fact would
|
|
34
|
+
* make the reconciliation depend on array order.
|
|
35
|
+
* - **Shape is nodes-free.** Whether a declared project reference can exist
|
|
36
|
+
* is a question about the captured baseline, so it belongs to the
|
|
37
|
+
* command (`./change.mjs`'s reference check), not here. A manifest must
|
|
38
|
+
* load and be told its references are wrong — loudly — rather than fail
|
|
39
|
+
* to parse at all.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
43
|
+
|
|
44
|
+
/** The only `version` this module accepts. A different value is a load error. */
|
|
45
|
+
export const CHANGE_INTENT_VERSION = "1";
|
|
46
|
+
|
|
47
|
+
/** The only keys a valid change-intent file may carry at the top level. */
|
|
48
|
+
export const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
|
|
49
|
+
"version",
|
|
50
|
+
"base",
|
|
51
|
+
"summary",
|
|
52
|
+
"projects",
|
|
53
|
+
"edges",
|
|
54
|
+
"constraints",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** The keys the `base` section may carry. */
|
|
58
|
+
export const CHANGE_INTENT_BASE_KEYS = Object.freeze(["commit"]);
|
|
59
|
+
|
|
60
|
+
/** The sub-keys the `projects` section may carry. */
|
|
61
|
+
export const CHANGE_INTENT_PROJECT_SECTION_KEYS = Object.freeze(["add", "remove"]);
|
|
62
|
+
/** The sub-keys the `edges` section may carry. */
|
|
63
|
+
export const CHANGE_INTENT_EDGE_SECTION_KEYS = Object.freeze(["add", "remove"]);
|
|
64
|
+
/** The keys an edge row may carry. */
|
|
65
|
+
export const CHANGE_INTENT_EDGE_ROW_KEYS = Object.freeze(["from", "to"]);
|
|
66
|
+
/** The keys the `constraints` section may carry. */
|
|
67
|
+
export const CHANGE_INTENT_CONSTRAINT_KEYS = Object.freeze(["noNewViolations", "noNewCycles"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The constraints the reconciliation judges, in the fixed order their verdict
|
|
71
|
+
* rows are emitted — the order itself is part of the deterministic output
|
|
72
|
+
* contract (`docs/reference/json-output.md`), so a manifest that declares them
|
|
73
|
+
* in either order gets the same row order.
|
|
74
|
+
*/
|
|
75
|
+
export const CONSTRAINT_ORDER = Object.freeze(["noNewViolations", "noNewCycles"]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The manifest key a constraint row answers to, keyed by the row name the
|
|
79
|
+
* report and JSON use. One copy, so a renamed key cannot desynchronize the
|
|
80
|
+
* loader's accepted set from the judge's row names.
|
|
81
|
+
*/
|
|
82
|
+
export const CONSTRAINT_ROW_NAMES = Object.freeze({
|
|
83
|
+
noNewViolations: "no-new-violations",
|
|
84
|
+
noNewCycles: "no-new-cycles",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** A value's type, for an error message that shows what was actually there. */
|
|
88
|
+
function describe(value) {
|
|
89
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
90
|
+
if (value === null) return "null";
|
|
91
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
95
|
+
const isPlainObject = (value) =>
|
|
96
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
97
|
+
|
|
98
|
+
/** `key` on `obj` that is not one of `allowed` — the reject-by-name rule. */
|
|
99
|
+
function unknownKeys(obj, allowed) {
|
|
100
|
+
return Object.keys(obj).filter((key) => !allowed.includes(key));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Non-empty-string guard with its message fragment. */
|
|
104
|
+
function nonEmptyString(value) {
|
|
105
|
+
return typeof value === "string" && value.length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Everything wrong with the `projects`/`edges` sections' shared list shape —
|
|
110
|
+
* present-or-absent wholesale, an array when present, strings (for projects)
|
|
111
|
+
* or `{from, to}` rows (for edges) inside, no duplicates within a list, no
|
|
112
|
+
* member of `add` also in `remove`. Written once for both sections because a
|
|
113
|
+
* second copy of the duplicate rule is how the two sections drift into
|
|
114
|
+
* answering "is this a set?" differently.
|
|
115
|
+
*
|
|
116
|
+
* @param {unknown} section The raw section value.
|
|
117
|
+
* @param {{name: string, keys: readonly string[], row: (row: unknown, at: string) => string[],
|
|
118
|
+
* identity: (row: unknown) => string|null}} spec
|
|
119
|
+
* `name` is the dotted path prefix, `keys` the allowed sub-keys, `row` the
|
|
120
|
+
* per-element validator, `identity` the element's dedup key (or `null` when
|
|
121
|
+
* the element is malformed and already reported).
|
|
122
|
+
* @returns {string[]}
|
|
123
|
+
*/
|
|
124
|
+
function sectionListViolations(section, spec) {
|
|
125
|
+
const problems = [];
|
|
126
|
+
if (section === undefined) return problems;
|
|
127
|
+
if (!isPlainObject(section)) {
|
|
128
|
+
problems.push(`${spec.name}: must be an object when present, got ${describe(section)}`);
|
|
129
|
+
return problems;
|
|
130
|
+
}
|
|
131
|
+
for (const key of unknownKeys(section, spec.keys)) {
|
|
132
|
+
problems.push(
|
|
133
|
+
`${spec.name}.${key}: unknown key — ${spec.name} may carry only ${spec.keys.join(", ")}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
/** @type {Map<string, number>} */
|
|
137
|
+
const seen = new Map();
|
|
138
|
+
for (const kind of spec.keys) {
|
|
139
|
+
const list = section[kind];
|
|
140
|
+
if (list === undefined) continue;
|
|
141
|
+
if (!Array.isArray(list)) {
|
|
142
|
+
problems.push(`${spec.name}.${kind}: must be an array when present, got ${describe(list)}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
list.forEach((entry, index) => {
|
|
146
|
+
const at = `${spec.name}.${kind}[${index}]`;
|
|
147
|
+
const rowProblems = spec.row(entry, at);
|
|
148
|
+
if (rowProblems.length > 0) {
|
|
149
|
+
problems.push(...rowProblems);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const id = /** @type {string} */ (spec.identity(entry));
|
|
153
|
+
const first = seen.get(id);
|
|
154
|
+
if (first !== undefined) {
|
|
155
|
+
problems.push(
|
|
156
|
+
`${at}: duplicates ${spec.name}.${kind}[${first}] (${id}) — the contract is a set ` +
|
|
157
|
+
`of expected facts, and a repeated declaration would make reconciliation depend ` +
|
|
158
|
+
`on array order`,
|
|
159
|
+
);
|
|
160
|
+
} else {
|
|
161
|
+
seen.set(id, index);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// An `add` and a `remove` naming the same fact cancel to nothing while
|
|
166
|
+
// reading as a declaration — refuse the pair rather than reconcile a
|
|
167
|
+
// contradiction.
|
|
168
|
+
if (Array.isArray(section.add) && Array.isArray(section.remove)) {
|
|
169
|
+
const removeIds = new Set(section.remove.map(spec.identity));
|
|
170
|
+
section.add.forEach((entry, index) => {
|
|
171
|
+
const id = spec.identity(entry);
|
|
172
|
+
if (id !== null && removeIds.has(id)) {
|
|
173
|
+
problems.push(
|
|
174
|
+
`${spec.name}.add[${index}]: "${id}" is also declared in ${spec.name}.remove — ` +
|
|
175
|
+
`a fact cannot be both expected to appear and expected to disappear`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return problems;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Everything wrong with a raw change-intent file, as messages; empty when it
|
|
185
|
+
* is well-formed. Pure — no baseline, no graph, no clock: whether a declared
|
|
186
|
+
* reference CAN exist is `findChangeIntentReferenceViolations`' question,
|
|
187
|
+
* below.
|
|
188
|
+
*
|
|
189
|
+
* @param {unknown} raw The parsed JSON value.
|
|
190
|
+
* @returns {string[]}
|
|
191
|
+
*/
|
|
192
|
+
export function findChangeIntentViolations(raw) {
|
|
193
|
+
const violations = [];
|
|
194
|
+
if (!isPlainObject(raw)) {
|
|
195
|
+
return [`top level: must be an object, got ${describe(raw)}`];
|
|
196
|
+
}
|
|
197
|
+
for (const key of unknownKeys(raw, CHANGE_INTENT_TOP_LEVEL_KEYS)) {
|
|
198
|
+
violations.push(
|
|
199
|
+
`unknown key "${key}" — a change intent may carry only ` +
|
|
200
|
+
`${CHANGE_INTENT_TOP_LEVEL_KEYS.join(", ")}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (raw.version !== CHANGE_INTENT_VERSION) {
|
|
205
|
+
violations.push(
|
|
206
|
+
`version: must be exactly "${CHANGE_INTENT_VERSION}", got ${describe(raw.version)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The base pin is the whole proof that reconciliation compares the same two
|
|
211
|
+
// architectures the author saw: without a commit there is nothing to verify
|
|
212
|
+
// the baseline against, and the run must answer unproven rather than guess
|
|
213
|
+
// (`./change.mjs`). So the section itself is required, not optional.
|
|
214
|
+
if (!isPlainObject(raw.base)) {
|
|
215
|
+
violations.push(`base: is required and must be an object, got ${describe(raw.base)}`);
|
|
216
|
+
} else {
|
|
217
|
+
for (const key of unknownKeys(raw.base, CHANGE_INTENT_BASE_KEYS)) {
|
|
218
|
+
violations.push(
|
|
219
|
+
`base.${key}: unknown key — base may carry only ${CHANGE_INTENT_BASE_KEYS.join(", ")}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!nonEmptyString(raw.base.commit)) {
|
|
223
|
+
violations.push(
|
|
224
|
+
"base.commit: is required and must be a non-empty string — the git commit the " +
|
|
225
|
+
"baseline snapshot was captured at (`archkeep delta --capture` records it in the " +
|
|
226
|
+
"snapshot's provenance)",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (raw.summary !== undefined && !nonEmptyString(raw.summary)) {
|
|
232
|
+
violations.push(
|
|
233
|
+
`summary: must be a non-empty string when present, got ${describe(raw.summary)} — it is ` +
|
|
234
|
+
"informational only and is never read by the reconciliation",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
violations.push(
|
|
239
|
+
...sectionListViolations(raw.projects, {
|
|
240
|
+
name: "projects",
|
|
241
|
+
keys: CHANGE_INTENT_PROJECT_SECTION_KEYS,
|
|
242
|
+
row: (entry, at) =>
|
|
243
|
+
nonEmptyString(entry)
|
|
244
|
+
? []
|
|
245
|
+
: [`${at}: must be a non-empty project name, got ${describe(entry)}`],
|
|
246
|
+
identity: (entry) => (typeof entry === "string" ? entry : ""),
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
violations.push(
|
|
251
|
+
...sectionListViolations(raw.edges, {
|
|
252
|
+
name: "edges",
|
|
253
|
+
keys: CHANGE_INTENT_EDGE_SECTION_KEYS,
|
|
254
|
+
row: (entry, at) => {
|
|
255
|
+
if (!isPlainObject(entry)) {
|
|
256
|
+
return [`${at}: must be an object with "from" and "to", got ${describe(entry)}`];
|
|
257
|
+
}
|
|
258
|
+
const problems = [];
|
|
259
|
+
for (const key of unknownKeys(entry, CHANGE_INTENT_EDGE_ROW_KEYS)) {
|
|
260
|
+
problems.push(
|
|
261
|
+
`${at}.${key}: unknown key — an edge row may carry only ` +
|
|
262
|
+
`${CHANGE_INTENT_EDGE_ROW_KEYS.join(", ")}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
for (const side of ["from", "to"]) {
|
|
266
|
+
if (!nonEmptyString(entry[side])) {
|
|
267
|
+
problems.push(
|
|
268
|
+
`${at}.${side}: must be a non-empty project name, got ${describe(entry[side])}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (nonEmptyString(entry.from) && entry.from === entry.to) {
|
|
273
|
+
problems.push(
|
|
274
|
+
`${at}: "from" and "to" are both "${entry.from}" — the project graph strips ` +
|
|
275
|
+
`self-edges, so a self-dependency can never be observed and the declaration ` +
|
|
276
|
+
`could never match`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return problems;
|
|
280
|
+
},
|
|
281
|
+
identity: (entry) =>
|
|
282
|
+
isPlainObject(entry) && nonEmptyString(entry.from) && nonEmptyString(entry.to)
|
|
283
|
+
? `${entry.from}\u0000${entry.to}`
|
|
284
|
+
: "",
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (raw.constraints !== undefined) {
|
|
289
|
+
if (!isPlainObject(raw.constraints)) {
|
|
290
|
+
violations.push(
|
|
291
|
+
`constraints: must be an object when present, got ${describe(raw.constraints)}`,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
for (const key of unknownKeys(raw.constraints, CHANGE_INTENT_CONSTRAINT_KEYS)) {
|
|
295
|
+
violations.push(
|
|
296
|
+
`constraints.${key}: unknown key — constraints may carry only ` +
|
|
297
|
+
`${CHANGE_INTENT_CONSTRAINT_KEYS.join(", ")}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
for (const key of CHANGE_INTENT_CONSTRAINT_KEYS) {
|
|
301
|
+
const value = raw.constraints[key];
|
|
302
|
+
if (value === undefined) continue;
|
|
303
|
+
if (value !== true) {
|
|
304
|
+
violations.push(
|
|
305
|
+
`constraints.${key}: must be exactly true when present, got ${describe(value)} — ` +
|
|
306
|
+
"a constraint this run should not judge is declared by omitting the key, not by " +
|
|
307
|
+
"writing false",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return violations;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Parses and validates change-intent text into the normalized shape the
|
|
319
|
+
* `change` command reconciles against. Pure: text in, validated contract out.
|
|
320
|
+
*
|
|
321
|
+
* Absent sections normalize to empty expectations — a manifest with no
|
|
322
|
+
* `projects` section expects no project to appear or disappear, which is a
|
|
323
|
+
* REAL expectation the reconciliation enforces, never an absence of one.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} text The file contents.
|
|
326
|
+
* @param {string} path The path the text came from, for error messages.
|
|
327
|
+
* @returns {{
|
|
328
|
+
* version: string,
|
|
329
|
+
* base: {commit: string},
|
|
330
|
+
* summary?: string,
|
|
331
|
+
* projects: {add: string[], remove: string[]},
|
|
332
|
+
* edges: {add: {from: string, to: string}[], remove: {from: string, to: string}[]},
|
|
333
|
+
* constraints: {noNewViolations?: true, noNewCycles?: true},
|
|
334
|
+
* }}
|
|
335
|
+
* @throws {Error} naming every violation at once — these become exit-3-class
|
|
336
|
+
* input errors upstream, and a run that consumed a malformed declaration
|
|
337
|
+
* silently would reconcile against an expectation nobody wrote.
|
|
338
|
+
*/
|
|
339
|
+
export function parseChangeIntent(text, path) {
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(text);
|
|
343
|
+
} catch (cause) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`archkeep: the change intent '${path}' is not valid JSON: ${cause?.message ?? cause}`,
|
|
346
|
+
{ cause },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const violations = findChangeIntentViolations(parsed);
|
|
350
|
+
if (violations.length > 0) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`archkeep: the change intent '${path}' is not a usable contract:\n ` +
|
|
353
|
+
violations.join("\n "),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
version: parsed.version,
|
|
358
|
+
base: { commit: parsed.base.commit },
|
|
359
|
+
...(parsed.summary === undefined ? {} : { summary: parsed.summary }),
|
|
360
|
+
projects: {
|
|
361
|
+
add: parsed.projects?.add ?? [],
|
|
362
|
+
remove: parsed.projects?.remove ?? [],
|
|
363
|
+
},
|
|
364
|
+
edges: {
|
|
365
|
+
add: (parsed.edges?.add ?? []).map(({ from, to }) => ({ from, to })),
|
|
366
|
+
remove: (parsed.edges?.remove ?? []).map(({ from, to }) => ({ from, to })),
|
|
367
|
+
},
|
|
368
|
+
constraints: { ...(parsed.constraints ?? {}) },
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Reads the change-intent file from a path — the module's one filesystem seam,
|
|
374
|
+
* injectable so tests and embedders drive validation without disk.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} path Absolute path to the manifest.
|
|
377
|
+
* @param {{read?: (path: string) => Promise<string>}} [io]
|
|
378
|
+
* @returns {Promise<object>} Whatever `parseChangeIntent` returns.
|
|
379
|
+
* @throws {Error} when the file cannot be read, and whatever
|
|
380
|
+
* `parseChangeIntent` throws.
|
|
381
|
+
*/
|
|
382
|
+
export async function readChangeIntent(path, io = {}) {
|
|
383
|
+
const read = io.read ?? ((p) => readFileFromDisk(p, "utf8"));
|
|
384
|
+
let text;
|
|
385
|
+
try {
|
|
386
|
+
text = await read(path);
|
|
387
|
+
} catch (cause) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`archkeep: cannot read the change intent '${path}': ${cause?.message ?? cause}`,
|
|
390
|
+
{ cause },
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return parseChangeIntent(text, path);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Everything wrong with a loaded manifest's REFERENCES — whether each declared
|
|
398
|
+
* fact could exist in a tree descended from the captured baseline. Pure: the
|
|
399
|
+
* baseline's project-name set arrives as an argument.
|
|
400
|
+
*
|
|
401
|
+
* These are load errors too (the command throws on the first list), because a
|
|
402
|
+
* declaration that references a project that exists nowhere would sit in
|
|
403
|
+
* `missingExpected` forever, reading as "the change forgot something" when
|
|
404
|
+
* the truth is "the declaration cannot be satisfied by ANY tree".
|
|
405
|
+
*
|
|
406
|
+
* @param {object} intent A `parseChangeIntent` result.
|
|
407
|
+
* @param {Set<string>} baselineProjects Every project name in the captured
|
|
408
|
+
* baseline's graph.
|
|
409
|
+
* @returns {string[]} One entry per problem, empty when every reference lands.
|
|
410
|
+
*/
|
|
411
|
+
export function findChangeIntentReferenceViolations(intent, baselineProjects) {
|
|
412
|
+
const violations = [];
|
|
413
|
+
const known = new Set([...baselineProjects, ...intent.projects.add]);
|
|
414
|
+
for (const name of intent.projects.add) {
|
|
415
|
+
if (baselineProjects.has(name)) {
|
|
416
|
+
violations.push(
|
|
417
|
+
`projects.add: "${name}" already exists at the captured base — a project the base ` +
|
|
418
|
+
`graph already has cannot be declared as appearing`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const name of intent.projects.remove) {
|
|
423
|
+
if (!baselineProjects.has(name)) {
|
|
424
|
+
violations.push(
|
|
425
|
+
`projects.remove: "${name}" does not exist at the captured base — there is no such ` +
|
|
426
|
+
`project to expect gone`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const [kind, list] of [
|
|
431
|
+
["add", intent.edges.add],
|
|
432
|
+
["remove", intent.edges.remove],
|
|
433
|
+
]) {
|
|
434
|
+
for (const { from, to } of list) {
|
|
435
|
+
for (const side of ["from", "to"]) {
|
|
436
|
+
const name = side === "from" ? from : to;
|
|
437
|
+
if (kind === "add") {
|
|
438
|
+
if (intent.projects.remove.includes(name)) {
|
|
439
|
+
violations.push(
|
|
440
|
+
`edges.${kind}: ${from} -> ${to} names "${name}" in projects.remove — an edge ` +
|
|
441
|
+
`cannot appear attached to a project declared to disappear`,
|
|
442
|
+
);
|
|
443
|
+
} else if (!known.has(name)) {
|
|
444
|
+
violations.push(
|
|
445
|
+
`edges.${kind}: ${from} -> ${to} names "${name}", which is neither in the ` +
|
|
446
|
+
`baseline graph nor declared in projects.add — the reference can never match ` +
|
|
447
|
+
`anything`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
} else if (!baselineProjects.has(name)) {
|
|
451
|
+
violations.push(
|
|
452
|
+
`edges.${kind}: ${from} -> ${to} names "${name}", which is not in the baseline ` +
|
|
453
|
+
`graph — an edge of a project the base does not have cannot be expected to ` +
|
|
454
|
+
`disappear${intent.projects.add.includes(name) ? " (it is declared in projects.add, so it had no base edges)" : ""}`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return violations;
|
|
461
|
+
}
|