@ecoma-io/archkeep 0.14.0 → 0.15.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 +9 -3
- package/cli.mjs +447 -59
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- 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 +40 -2
- 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/trajectory.mjs +437 -0
- package/src/path-util.mjs +40 -0
- 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/tsconfig-paths.mjs +3 -2
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `change` command: a declared change-intent contract reconciled against
|
|
3
|
+
* the actual architectural delta.
|
|
4
|
+
*
|
|
5
|
+
* Three questions stay separate in this repository, and this module exists to
|
|
6
|
+
* keep them that way. Whether the tree obeys the workspace's long-lived law is
|
|
7
|
+
* `check`'s verdict. Why an architectural state arose is history's question.
|
|
8
|
+
* THIS command answers only: **did the change produce exactly the material
|
|
9
|
+
* architectural consequences its contract declared?** A change can be
|
|
10
|
+
* policy-compliant but undeclared, policy-invalid but intent-matched, both,
|
|
11
|
+
* or neither — every combination is reported on independent axes, and none is
|
|
12
|
+
* collapsed into another.
|
|
13
|
+
*
|
|
14
|
+
* The material delta is not recomputed here. It is `./diff.mjs`'s
|
|
15
|
+
* `computeDiff` over the baseline evidence snapshot's stored graph and the
|
|
16
|
+
* head graph — projects added/removed/changed, edges added/removed — so a
|
|
17
|
+
* "material architectural change" means exactly what `diff` has always meant,
|
|
18
|
+
* never this module's second opinion about which edits count. Constraint
|
|
19
|
+
* evaluation re-judges BOTH sides through the same engine under ONE current
|
|
20
|
+
* law (`../rules/index.mjs` + `./delta-classify.mjs`, the `delta` arrangement)
|
|
21
|
+
* and counts cycles through `../governance/fitness-rules.mjs`'s
|
|
22
|
+
* `cyclicProjects`; no verdict below re-derives what those functions already
|
|
23
|
+
* answer. The workspace-law axis is informational — computed through the same
|
|
24
|
+
* engine, labeled as such, and never folded into this command's exit code:
|
|
25
|
+
* `check` remains the authority on the law.
|
|
26
|
+
*
|
|
27
|
+
* ## Verdicts, and why unfulfilled is its own state
|
|
28
|
+
*
|
|
29
|
+
* - `matched` — every declared fact is observed, nothing else changed.
|
|
30
|
+
* - `undeclared` — at least one OBSERVED material change no declaration
|
|
31
|
+
* covers. A review signal, not a governance failure: the result may still
|
|
32
|
+
* be legal architecture that simply was not promised.
|
|
33
|
+
* - `unfulfilled` — nothing undeclared, but at least one declared change
|
|
34
|
+
* never happened. Proven divergence between plan and outcome — not a
|
|
35
|
+
* failure to look, and never read as matched.
|
|
36
|
+
* - `unproven` — the base identity could not be established (the manifest's
|
|
37
|
+
* `base.commit` against the baseline's provenance), so no comparison this
|
|
38
|
+
* run makes can be attached to the architectures the author saw.
|
|
39
|
+
* Unproven MUST NOT become matched; when it holds, constraint rows are
|
|
40
|
+
* left unevaluated rather than reported over a base this run cannot vouch
|
|
41
|
+
* for.
|
|
42
|
+
*
|
|
43
|
+
* When unexpected and missing expectations coexist, the verdict reads
|
|
44
|
+
* `undeclared` — the surprise is the reviewer's first question — and both
|
|
45
|
+
* lists are always present in full, so precedence hides nothing.
|
|
46
|
+
*
|
|
47
|
+
* ## Exit fold
|
|
48
|
+
*
|
|
49
|
+
* `undeclared` or `unfulfilled`, or a failed declared constraint, is a
|
|
50
|
+
* finding (exit 1) — the fourth verb whose verdict carries it, beside
|
|
51
|
+
* `check`, `fitness` and `delta`. An unproven identity or an undeterminable
|
|
52
|
+
* constraint is a no-verdict (exit 3). Matched with every declared constraint
|
|
53
|
+
* passing is ok (exit 0). The workspace-law axis rides the envelope as
|
|
54
|
+
* evidence only.
|
|
55
|
+
*
|
|
56
|
+
* Refusals (each a throw → exit 3 upstream): a manifest that fails shape or
|
|
57
|
+
* reference validation, an unreadable/malformed/foreign-schema baseline,
|
|
58
|
+
* incomplete baseline coverage, a provider mismatch, incomplete head
|
|
59
|
+
* coverage, an unregistered-plugin graph over polyglot manifests, and a run
|
|
60
|
+
* with no boundary law (constraints and the law fingerprint need one).
|
|
61
|
+
*
|
|
62
|
+
* This module computes and returns; `../../cli.mjs`'s `runChange` owns argv,
|
|
63
|
+
* output destination and the process exit code (`./README.md`).
|
|
64
|
+
*/
|
|
65
|
+
import { classifyDelta } from "./delta-classify.mjs";
|
|
66
|
+
import { computeDiff } from "./diff.mjs";
|
|
67
|
+
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
68
|
+
import {
|
|
69
|
+
CONSTRAINT_ORDER,
|
|
70
|
+
CONSTRAINT_ROW_NAMES,
|
|
71
|
+
findChangeIntentReferenceViolations,
|
|
72
|
+
readChangeIntent,
|
|
73
|
+
} from "./change-intent.mjs";
|
|
74
|
+
import {
|
|
75
|
+
evidenceGraphToProjectGraph,
|
|
76
|
+
refuseUnjudgeableHead,
|
|
77
|
+
sourceProjectAttributor,
|
|
78
|
+
} from "./delta.mjs";
|
|
79
|
+
import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
|
|
80
|
+
import { cyclicProjects } from "../governance/fitness-rules.mjs";
|
|
81
|
+
import { fitnessVerdict } from "../governance/verdict.mjs";
|
|
82
|
+
import { buildDecision } from "../report/evidence.mjs";
|
|
83
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
84
|
+
import { formatChangeReport } from "../report/change-text.mjs";
|
|
85
|
+
import { evaluateRun } from "../rules/index.mjs";
|
|
86
|
+
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
87
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One expected-fact row as the report and JSON carry it. Kept in one builder
|
|
91
|
+
* so every list entry carries the same five-field vocabulary and a consumer
|
|
92
|
+
* branches on `kind` alone.
|
|
93
|
+
*
|
|
94
|
+
* @param {{kind: string, project?: string, from?: string, to?: string,
|
|
95
|
+
* type?: string, changes?: object[]}} fields
|
|
96
|
+
* @returns {object}
|
|
97
|
+
*/
|
|
98
|
+
const factRow = ({ kind, ...rest }) => ({ kind, ...rest });
|
|
99
|
+
|
|
100
|
+
/** Plain lexicographic comparison — never localeCompare (byte-determinism). */
|
|
101
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
102
|
+
|
|
103
|
+
/** Sort key for fact rows: kind first, then the identity fields in order. */
|
|
104
|
+
function cmpFacts(a, b) {
|
|
105
|
+
return (
|
|
106
|
+
cmp(a.kind, b.kind) ||
|
|
107
|
+
cmp(a.project ?? "", b.project ?? "") ||
|
|
108
|
+
cmp(a.from ?? "", b.from ?? "") ||
|
|
109
|
+
cmp(a.to ?? "", b.to ?? "") ||
|
|
110
|
+
cmp(a.type ?? "", b.type ?? "")
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reconciles a normalized change-intent contract against the structural diff
|
|
116
|
+
* of the two graphs. Pure — the heart of the command, and the function a
|
|
117
|
+
* future capability (trend context, predicted risk) composes rather than
|
|
118
|
+
* replaces.
|
|
119
|
+
*
|
|
120
|
+
* Edge expectations match on `(from, to)` alone, deliberately: whether the
|
|
121
|
+
* graph emits the dependency as a `static` or `dynamic` edge is the model's
|
|
122
|
+
* spelling, not the author's promise, and requiring it would force the agent
|
|
123
|
+
* to predict implementation detail. The observed type rides the row either
|
|
124
|
+
* way. A project whose tags/type/root CHANGED between the sides has no
|
|
125
|
+
* declaration surface in this version — any such row lands in
|
|
126
|
+
* `unexpected` (`kind: "project-changed"`), the loud direction, because a
|
|
127
|
+
* tag change alters which rows of the boundary law reach the project.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} intent A `parseChangeIntent` result.
|
|
130
|
+
* @param {{addedProjects: object[], removedProjects: object[],
|
|
131
|
+
* changedProjects: object[], addedEdges: object[], removedEdges: object[]}}
|
|
132
|
+
* delta `computeDiff`'s output for baseline vs head.
|
|
133
|
+
* @returns {{matched: object[], unexpected: object[], missingExpected: object[]}}
|
|
134
|
+
*/
|
|
135
|
+
export function reconcileMaterialDelta(intent, delta) {
|
|
136
|
+
const addedProjectNames = new Set(intent.projects.add);
|
|
137
|
+
const removedProjectNames = new Set(intent.projects.remove);
|
|
138
|
+
const edgeKey = ({ from, to }) => `${from}\u0000${to}`;
|
|
139
|
+
const addEdgeKeys = new Set(intent.edges.add.map(edgeKey));
|
|
140
|
+
const removeEdgeKeys = new Set(intent.edges.remove.map(edgeKey));
|
|
141
|
+
|
|
142
|
+
/** @type {object[]} */
|
|
143
|
+
const matched = [];
|
|
144
|
+
/** @type {object[]} */
|
|
145
|
+
const unexpected = [];
|
|
146
|
+
|
|
147
|
+
for (const project of delta.addedProjects) {
|
|
148
|
+
const row = factRow({ kind: "project-added", project: project.name });
|
|
149
|
+
if (addedProjectNames.has(project.name)) matched.push(row);
|
|
150
|
+
else unexpected.push(row);
|
|
151
|
+
}
|
|
152
|
+
for (const project of delta.removedProjects) {
|
|
153
|
+
const row = factRow({ kind: "project-removed", project: project.name });
|
|
154
|
+
if (removedProjectNames.has(project.name)) matched.push(row);
|
|
155
|
+
else unexpected.push(row);
|
|
156
|
+
}
|
|
157
|
+
for (const edge of delta.addedEdges) {
|
|
158
|
+
const row = factRow({
|
|
159
|
+
kind: "edge-added",
|
|
160
|
+
from: edge.source,
|
|
161
|
+
to: edge.target,
|
|
162
|
+
type: edge.type,
|
|
163
|
+
});
|
|
164
|
+
if (addEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
|
|
165
|
+
else unexpected.push(row);
|
|
166
|
+
}
|
|
167
|
+
for (const edge of delta.removedEdges) {
|
|
168
|
+
const row = factRow({
|
|
169
|
+
kind: "edge-removed",
|
|
170
|
+
from: edge.source,
|
|
171
|
+
to: edge.target,
|
|
172
|
+
type: edge.type,
|
|
173
|
+
});
|
|
174
|
+
if (removeEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
|
|
175
|
+
else unexpected.push(row);
|
|
176
|
+
}
|
|
177
|
+
// No declaration surface in this version: a metadata change to a project
|
|
178
|
+
// both sides have is always a surprise. Loud by construction — the silent
|
|
179
|
+
// alternative would be dropping it, and a tag change moves the law.
|
|
180
|
+
for (const changed of delta.changedProjects) {
|
|
181
|
+
unexpected.push(
|
|
182
|
+
factRow({
|
|
183
|
+
kind: "project-changed",
|
|
184
|
+
project: changed.name,
|
|
185
|
+
changes: changed.changes,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const observedAdded = new Set(delta.addedProjects.map((p) => p.name));
|
|
191
|
+
const observedRemoved = new Set(delta.removedProjects.map((p) => p.name));
|
|
192
|
+
const observedAddEdges = new Set(
|
|
193
|
+
delta.addedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
|
|
194
|
+
);
|
|
195
|
+
const observedRemoveEdges = new Set(
|
|
196
|
+
delta.removedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Declared but never observed — the unfulfilled half, kept apart from
|
|
200
|
+
// `unexpected` because "the change did extra" and "the change skipped its
|
|
201
|
+
// own plan" are different review conversations.
|
|
202
|
+
/** @type {object[]} */
|
|
203
|
+
const missingExpected = [];
|
|
204
|
+
for (const name of intent.projects.add) {
|
|
205
|
+
if (!observedAdded.has(name))
|
|
206
|
+
missingExpected.push(factRow({ kind: "project-added", project: name }));
|
|
207
|
+
}
|
|
208
|
+
for (const name of intent.projects.remove) {
|
|
209
|
+
if (!observedRemoved.has(name)) {
|
|
210
|
+
missingExpected.push(factRow({ kind: "project-removed", project: name }));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const edge of intent.edges.add) {
|
|
214
|
+
if (!observedAddEdges.has(edgeKey(edge))) {
|
|
215
|
+
missingExpected.push(factRow({ kind: "edge-added", from: edge.from, to: edge.to }));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const edge of intent.edges.remove) {
|
|
219
|
+
if (!observedRemoveEdges.has(edgeKey(edge))) {
|
|
220
|
+
missingExpected.push(factRow({ kind: "edge-removed", from: edge.from, to: edge.to }));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
matched: matched.sort(cmpFacts),
|
|
226
|
+
unexpected: unexpected.sort(cmpFacts),
|
|
227
|
+
missingExpected: missingExpected.sort(cmpFacts),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The reconciliation verdict from its three lists — pure, and the one place
|
|
233
|
+
* the precedence is stated. Precedence hides nothing: every list rides the
|
|
234
|
+
* envelope in full whatever the verdict says.
|
|
235
|
+
*
|
|
236
|
+
* @param {{unexpected: object[], missingExpected: object[]}} lists
|
|
237
|
+
* @param {string[]} unprovenReasons Why the base identity could not be proven.
|
|
238
|
+
* @returns {"matched"|"undeclared"|"unfulfilled"|"unproven"}
|
|
239
|
+
*/
|
|
240
|
+
export function reconciliationVerdict(lists, unprovenReasons) {
|
|
241
|
+
if (unprovenReasons.length > 0) return "unproven";
|
|
242
|
+
if (lists.unexpected.length > 0) return "undeclared";
|
|
243
|
+
if (lists.missingExpected.length > 0) return "unfulfilled";
|
|
244
|
+
return "matched";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Judges the constraints the contract declares, through the shared engine —
|
|
249
|
+
* both sides re-judged under the CURRENT law and one shared instant, exactly
|
|
250
|
+
* the `delta` arrangement, so a policy edit between capture and verify cannot
|
|
251
|
+
* fabricate a pass or a fail. Rows appear only for DECLARED constraints: an
|
|
252
|
+
* omitted key was never asserted, and inventing a row for it would report a
|
|
253
|
+
* verdict nobody asked for.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} intent A `parseChangeIntent` result.
|
|
256
|
+
* @param {object} io Everything the judges share: the raw violation arrays
|
|
257
|
+
* and record arrays for both sides, the rebuilt base graph and the head
|
|
258
|
+
* graph, the current config, the shared instant, and the attribution
|
|
259
|
+
* function `classifyDelta` needs for unresolvable records.
|
|
260
|
+
* @returns {object[]} `fitnessVerdict` rows in `CONSTRAINT_ORDER` order.
|
|
261
|
+
*/
|
|
262
|
+
function judgeDeclaredConstraints(intent, io) {
|
|
263
|
+
/** @type {object[]} */
|
|
264
|
+
const rows = [];
|
|
265
|
+
const introducedUnknown =
|
|
266
|
+
io.classification.violations.unknown.length + io.classification.unresolvable.unknown.length;
|
|
267
|
+
for (const constraint of CONSTRAINT_ORDER) {
|
|
268
|
+
if (intent.constraints[constraint] !== true) continue;
|
|
269
|
+
const name = CONSTRAINT_ROW_NAMES[constraint];
|
|
270
|
+
if (constraint === "noNewViolations") {
|
|
271
|
+
if (introducedUnknown > 0) {
|
|
272
|
+
rows.push(
|
|
273
|
+
fitnessVerdict({
|
|
274
|
+
verdict: "unknown",
|
|
275
|
+
name,
|
|
276
|
+
evidence: { introduced: io.classification.violations.introduced.length },
|
|
277
|
+
message:
|
|
278
|
+
`${introducedUnknown} delta item${introducedUnknown === 1 ? "" : "s"} could not ` +
|
|
279
|
+
"be classified, so the introduced set may be incomplete — an unknown item is " +
|
|
280
|
+
"never read as a clean introduction",
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const introducedNotWaived = io.classification.violations.introduced.filter(
|
|
286
|
+
(entry) => entry.waived !== true,
|
|
287
|
+
);
|
|
288
|
+
rows.push(
|
|
289
|
+
fitnessVerdict({
|
|
290
|
+
verdict: introducedNotWaived.length === 0 ? "pass" : "fail",
|
|
291
|
+
name,
|
|
292
|
+
evidence: {
|
|
293
|
+
introduced: io.classification.violations.introduced.length,
|
|
294
|
+
introducedWaived:
|
|
295
|
+
io.classification.violations.introduced.length - introducedNotWaived.length,
|
|
296
|
+
},
|
|
297
|
+
message:
|
|
298
|
+
introducedNotWaived.length === 0
|
|
299
|
+
? "no non-waived violation was introduced between the captured base and this tree"
|
|
300
|
+
: `${introducedNotWaived.length} non-waived violation${introducedNotWaived.length === 1 ? "" : "s"} introduced since the captured base`,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (constraint === "noNewCycles") {
|
|
306
|
+
const newlyCyclic = io.cyclesHead.filter((project) => !io.cyclesBase.includes(project));
|
|
307
|
+
rows.push(
|
|
308
|
+
fitnessVerdict({
|
|
309
|
+
verdict: newlyCyclic.length === 0 ? "pass" : "fail",
|
|
310
|
+
name,
|
|
311
|
+
evidence: {
|
|
312
|
+
baseCyclicProjects: io.cyclesBase,
|
|
313
|
+
headCyclicProjects: io.cyclesHead,
|
|
314
|
+
newCyclicProjects: newlyCyclic,
|
|
315
|
+
},
|
|
316
|
+
message:
|
|
317
|
+
newlyCyclic.length === 0
|
|
318
|
+
? "no project sits on a cycle at head that did not sit on one at the captured base"
|
|
319
|
+
: `${newlyCyclic.length} project${newlyCyclic.length === 1 ? " is" : "s are"} on a cycle at head that ${newlyCyclic.length === 1 ? "was" : "were"} acyclic at the captured base: ${newlyCyclic.join(", ")}`,
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return rows;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Runs the `change` command: loads the contract and the baseline, proves the
|
|
329
|
+
* base identity, computes the structural delta through `diff`'s own function,
|
|
330
|
+
* reconciles, judges declared constraints, and folds everything into a
|
|
331
|
+
* verdict the process exit code maps onto.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} baselinePath Absolute path to the evidence snapshot
|
|
334
|
+
* (`archkeep delta --capture` output).
|
|
335
|
+
* @param {string} intentPath Absolute path to the change-intent manifest.
|
|
336
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
337
|
+
* @param {{config?: object|null, readBaseline?: (path: string) => object,
|
|
338
|
+
* readIntent?: (path: string) => Promise<object>, now?: string}} [io]
|
|
339
|
+
* The resolved boundary config (required — constraints are judged under it
|
|
340
|
+
* and the fingerprint identifies the law), injectable readers so tests drive
|
|
341
|
+
* the command without disk, and the shared reference instant. Both readers
|
|
342
|
+
* return VALIDATED domain objects — `readEvidenceSnapshot`'s parse and
|
|
343
|
+
* `parseChangeIntent`'s normalized contract respectively — the same seam
|
|
344
|
+
* contract `deltaCommand`'s `readBaseline` states; an injector handing back
|
|
345
|
+
* raw file contents skips the grammar this command trusts.
|
|
346
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict",
|
|
347
|
+
* changeIntent: object, coverage: object, report: {text: string, json: string}}>}
|
|
348
|
+
* @throws {Error} on every refusal the module header lists.
|
|
349
|
+
*/
|
|
350
|
+
export async function changeCommand(
|
|
351
|
+
baselinePath,
|
|
352
|
+
intentPath,
|
|
353
|
+
commandContext,
|
|
354
|
+
{ config, readBaseline = readEvidenceSnapshot, readIntent, now } = {},
|
|
355
|
+
) {
|
|
356
|
+
const { root, provider, marker, graph, analysis } = commandContext;
|
|
357
|
+
|
|
358
|
+
// The one required member of `io`: constraints are judged under the current
|
|
359
|
+
// law and the envelope records which law that was, so an undefined config
|
|
360
|
+
// refuses here rather than degrading either.
|
|
361
|
+
if (config === undefined || config === null) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
"archkeep: cannot reconcile a change intent without a boundary config — the declared " +
|
|
364
|
+
"constraints are judged under the current law and the envelope records which law that " +
|
|
365
|
+
"was, and a run that resolves no law has neither.",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
refuseUnjudgeableHead(commandContext, "reconcile a change intent");
|
|
370
|
+
|
|
371
|
+
const intent = await (readIntent ? readIntent(intentPath) : readChangeIntent(intentPath));
|
|
372
|
+
|
|
373
|
+
const baseline = readBaseline(baselinePath);
|
|
374
|
+
|
|
375
|
+
// Provider mismatch refuses here exactly as `delta` refuses: project
|
|
376
|
+
// identity across two different models is not trustworthy, and matching a
|
|
377
|
+
// declaration against names that may be artefacts would fabricate both
|
|
378
|
+
// directions of the verdict.
|
|
379
|
+
const mismatch = providerMismatch(baseline.provider, provider);
|
|
380
|
+
if (mismatch !== null) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`archkeep: refusing to reconcile a change intent — ${mismatch}. Re-capture the ` +
|
|
383
|
+
`baseline under this run's provider.`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Reference validation before anything else consumes the declarations: a
|
|
388
|
+
// fact that cannot exist in ANY descendant of this base is a defect in the
|
|
389
|
+
// input, not a divergence to report.
|
|
390
|
+
const referenceViolations = findChangeIntentReferenceViolations(
|
|
391
|
+
intent,
|
|
392
|
+
new Set(baseline.graph.projects.map((project) => project.name)),
|
|
393
|
+
);
|
|
394
|
+
if (referenceViolations.length > 0) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`archkeep: the change intent '${intentPath}' references architecture the captured ` +
|
|
397
|
+
`baseline does not contain:\n ${referenceViolations.join("\n ")}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Base identity. The commit pin is the proof that the two sides compared
|
|
402
|
+
// are the ones the author saw when writing the contract; without it the
|
|
403
|
+
// run answers unproven with the reason named, never proceeds silently.
|
|
404
|
+
const headProvenance = resolveProvenance(root);
|
|
405
|
+
const headFingerprint = computePolicyFingerprint(config);
|
|
406
|
+
/** @type {string[]} */
|
|
407
|
+
const unprovenReasons = [];
|
|
408
|
+
if (!baseline.provenance || typeof baseline.provenance.commit !== "string") {
|
|
409
|
+
unprovenReasons.push(
|
|
410
|
+
`the baseline '${baselinePath}' carries no provenance, so the contract's base.commit ` +
|
|
411
|
+
`(${intent.base.commit}) cannot be verified against the tree it was captured from`,
|
|
412
|
+
);
|
|
413
|
+
} else if (baseline.provenance.commit !== intent.base.commit) {
|
|
414
|
+
unprovenReasons.push(
|
|
415
|
+
`the contract pins base.commit ${intent.base.commit}, but the baseline was captured at ` +
|
|
416
|
+
`${baseline.provenance.commit} — comparing against a different base than the one the ` +
|
|
417
|
+
`author declared`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const meta = compareSnapshotMetadata({
|
|
422
|
+
baselineProvider: baseline.provider,
|
|
423
|
+
headProvider: provider,
|
|
424
|
+
baselineProvenance: baseline.provenance,
|
|
425
|
+
headProvenance,
|
|
426
|
+
baselineFingerprint: baseline.policyFingerprint,
|
|
427
|
+
headFingerprint,
|
|
428
|
+
});
|
|
429
|
+
if (meta.crossRepo) {
|
|
430
|
+
unprovenReasons.push(
|
|
431
|
+
`the baseline's remote (${baseline.provenance?.remote}) differs from this tree's remote ` +
|
|
432
|
+
`(${headProvenance?.remote}) — the comparison may span unrelated repositories`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const baseGraphForDiff = {
|
|
437
|
+
projects: baseline.graph.projects,
|
|
438
|
+
dependencies: baseline.graph.dependencies,
|
|
439
|
+
};
|
|
440
|
+
const headGraphForDiff = {
|
|
441
|
+
projects: buildProjects(graph.nodes),
|
|
442
|
+
dependencies: buildDependencies(graph.dependencies),
|
|
443
|
+
};
|
|
444
|
+
const structural = computeDiff(baseGraphForDiff, headGraphForDiff);
|
|
445
|
+
const reconciliation = reconcileMaterialDelta(intent, structural);
|
|
446
|
+
|
|
447
|
+
// Constraints and the law axis are computed over both sides re-judged under
|
|
448
|
+
// ONE current law — the `delta` arrangement. Left unevaluated when the base
|
|
449
|
+
// identity is unproven: a constraint pass over evidence this run cannot
|
|
450
|
+
// attach to the declared base would read as a verdict about the wrong
|
|
451
|
+
// trees.
|
|
452
|
+
/** @type {object[]} */
|
|
453
|
+
let constraints = [];
|
|
454
|
+
let liveViolations = null;
|
|
455
|
+
if (unprovenReasons.length === 0) {
|
|
456
|
+
const configWithNow = now === undefined ? config : { ...config, now };
|
|
457
|
+
const baseEngineGraph = evidenceGraphToProjectGraph(baseline.graph);
|
|
458
|
+
const baseRaw = evaluateRun(baseline.records, baseEngineGraph, configWithNow).rawViolations;
|
|
459
|
+
const headEval = evaluateRun(analysis.imports, graph, configWithNow);
|
|
460
|
+
const classification = classifyDelta({
|
|
461
|
+
baseViolations: baseRaw,
|
|
462
|
+
headViolations: headEval.rawViolations,
|
|
463
|
+
baseRecords: baseline.records,
|
|
464
|
+
headRecords: analysis.imports,
|
|
465
|
+
suppressions: config.suppressions ?? [],
|
|
466
|
+
...(now === undefined ? {} : { now }),
|
|
467
|
+
sourceProjectOf: sourceProjectAttributor(graph, baseline.graph.projects),
|
|
468
|
+
});
|
|
469
|
+
constraints = judgeDeclaredConstraints(intent, {
|
|
470
|
+
classification,
|
|
471
|
+
cyclesBase: cyclicProjects(baseEngineGraph, Object.keys(baseEngineGraph.nodes)),
|
|
472
|
+
cyclesHead: cyclicProjects(graph, Object.keys(graph.nodes)),
|
|
473
|
+
});
|
|
474
|
+
// The informational law axis: what `check` would count right now, after
|
|
475
|
+
// the table — computed here so the envelope can show it beside the
|
|
476
|
+
// intent verdict WITHOUT becoming a second gate over it.
|
|
477
|
+
liveViolations = headEval.violations.length;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const verdict = reconciliationVerdict(reconciliation, unprovenReasons);
|
|
481
|
+
const failedConstraints = constraints.filter((row) => row.verdict === "fail").length;
|
|
482
|
+
const unknownConstraints = constraints.filter((row) => row.verdict === "unknown").length;
|
|
483
|
+
const findings =
|
|
484
|
+
reconciliation.unexpected.length + reconciliation.missingExpected.length + failedConstraints;
|
|
485
|
+
|
|
486
|
+
/** @type {"ok"|"findings"|"no-verdict"} */
|
|
487
|
+
let status;
|
|
488
|
+
/** @type {0|1|3} */
|
|
489
|
+
let exitCode;
|
|
490
|
+
let decision;
|
|
491
|
+
if (verdict === "unproven" || unknownConstraints > 0) {
|
|
492
|
+
status = "no-verdict";
|
|
493
|
+
exitCode = 3;
|
|
494
|
+
decision = buildDecision({
|
|
495
|
+
status,
|
|
496
|
+
coverageComplete: true,
|
|
497
|
+
findings: 0,
|
|
498
|
+
reason:
|
|
499
|
+
verdict === "unproven"
|
|
500
|
+
? `the change intent could not be verified against the declared base: ${unprovenReasons[0]}`
|
|
501
|
+
: `${unknownConstraints} declared constraint${unknownConstraints === 1 ? "" : "s"} could not be determined`,
|
|
502
|
+
});
|
|
503
|
+
} else if (findings > 0) {
|
|
504
|
+
status = "findings";
|
|
505
|
+
exitCode = 1;
|
|
506
|
+
decision = buildDecision({ status, coverageComplete: true, findings });
|
|
507
|
+
} else {
|
|
508
|
+
status = "ok";
|
|
509
|
+
exitCode = 0;
|
|
510
|
+
decision = buildDecision({ status, coverageComplete: true, findings: 0 });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** @type {string[]} */
|
|
514
|
+
const notes = [];
|
|
515
|
+
if (meta.policyChanged === true) {
|
|
516
|
+
notes.push(
|
|
517
|
+
"the boundary law changed since capture — constraints were re-judged under the current " +
|
|
518
|
+
"law applied to both sides, so a finding a policy edit created classifies as unchanged",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (meta.dirtyBaseline) {
|
|
522
|
+
notes.push(
|
|
523
|
+
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
524
|
+
"claim about the commit the contract pins",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (meta.dirtyHead) {
|
|
528
|
+
notes.push(
|
|
529
|
+
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
530
|
+
"commit HEAD names",
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if (meta.provenanceOneSided) {
|
|
534
|
+
notes.push(
|
|
535
|
+
"only one side carries repository provenance — the comparison cannot confirm both sides " +
|
|
536
|
+
"come from the same repository",
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const coverage = {
|
|
541
|
+
complete: true,
|
|
542
|
+
projects: headGraphForDiff.projects.length,
|
|
543
|
+
analyzedFiles: analysis.analyzed,
|
|
544
|
+
imports: analysis.imports.length,
|
|
545
|
+
notAnalyzed: [],
|
|
546
|
+
blindSpots: [],
|
|
547
|
+
notes,
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const result = {
|
|
551
|
+
intent: {
|
|
552
|
+
file: intentPath,
|
|
553
|
+
version: intent.version,
|
|
554
|
+
base: { ...intent.base },
|
|
555
|
+
...(intent.summary === undefined ? {} : { summary: intent.summary }),
|
|
556
|
+
declared: {
|
|
557
|
+
projectsAdd: intent.projects.add.length,
|
|
558
|
+
projectsRemove: intent.projects.remove.length,
|
|
559
|
+
edgesAdd: intent.edges.add.length,
|
|
560
|
+
edgesRemove: intent.edges.remove.length,
|
|
561
|
+
constraints: CONSTRAINT_ORDER.filter((key) => intent.constraints[key] === true),
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
baseline: {
|
|
565
|
+
path: baselinePath,
|
|
566
|
+
tool: baseline.tool,
|
|
567
|
+
provider: baseline.provider,
|
|
568
|
+
provenance: baseline.provenance,
|
|
569
|
+
policyFingerprint: baseline.policyFingerprint,
|
|
570
|
+
projects: baseline.graph.projects.length,
|
|
571
|
+
records: baseline.records.length,
|
|
572
|
+
},
|
|
573
|
+
head: {
|
|
574
|
+
provenance: headProvenance,
|
|
575
|
+
policyFingerprint: headFingerprint,
|
|
576
|
+
projects: headGraphForDiff.projects.length,
|
|
577
|
+
},
|
|
578
|
+
reconciliation: {
|
|
579
|
+
verdict,
|
|
580
|
+
reasons: unprovenReasons,
|
|
581
|
+
matched: reconciliation.matched,
|
|
582
|
+
unexpected: reconciliation.unexpected,
|
|
583
|
+
missingExpected: reconciliation.missingExpected,
|
|
584
|
+
},
|
|
585
|
+
constraints,
|
|
586
|
+
policy: {
|
|
587
|
+
fingerprint: headFingerprint,
|
|
588
|
+
changedSinceBase: meta.policyChanged === true,
|
|
589
|
+
liveViolations,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const envelope = jsonEnvelope({
|
|
594
|
+
command: "change",
|
|
595
|
+
context: { root, provider, marker, provenance: headProvenance },
|
|
596
|
+
status,
|
|
597
|
+
exitCode,
|
|
598
|
+
coverage,
|
|
599
|
+
result,
|
|
600
|
+
decision,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
status,
|
|
605
|
+
changeIntent: result,
|
|
606
|
+
coverage,
|
|
607
|
+
report: {
|
|
608
|
+
text: formatChangeReport({ change: result, coverage }),
|
|
609
|
+
json: renderJson(envelope),
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
package/src/commands/check.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { join } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
16
16
|
import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
|
|
17
|
+
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
17
18
|
import { suppressionCovers } from "../config.mjs";
|
|
18
19
|
import { referenceTime } from "../governance/clock.mjs";
|
|
19
20
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
@@ -155,7 +156,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
|
155
156
|
// spelling) — and `./project.json` is a different string from
|
|
156
157
|
// `project.json` to every consumer that compares paths, this file's own
|
|
157
158
|
// SARIF `uri` included.
|
|
158
|
-
const scoped = typeof root === "string" ? root
|
|
159
|
+
const scoped = typeof root === "string" ? stripTrailingSlashes(root) : "";
|
|
159
160
|
return scoped === "" || scoped === "." ? site.file : `${scoped}/${site.file}`;
|
|
160
161
|
}
|
|
161
162
|
/**
|
package/src/commands/context.mjs
CHANGED
|
@@ -802,8 +802,7 @@ export function resolveCommandContext(
|
|
|
802
802
|
// can name it correctly.
|
|
803
803
|
return {
|
|
804
804
|
root,
|
|
805
|
-
|
|
806
|
-
marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
|
|
805
|
+
...workspaceNames(hasMoon, hasNative, moonMarker),
|
|
807
806
|
graph,
|
|
808
807
|
workspace,
|
|
809
808
|
tracked,
|
|
@@ -821,6 +820,45 @@ export function resolveCommandContext(
|
|
|
821
820
|
};
|
|
822
821
|
}
|
|
823
822
|
|
|
823
|
+
/**
|
|
824
|
+
* The provider/marker pair a workspace root carries, derived from marker
|
|
825
|
+
* presence through the one mapping every envelope header must agree on.
|
|
826
|
+
* `resolveCommandContext` reads it for its own context; a caller that needs
|
|
827
|
+
* the identity WITHOUT judging the tree — an envelope header over a run whose
|
|
828
|
+
* analyzed revisions each carry their own contexts (`./evolution.mjs`) —
|
|
829
|
+
* calls `describeWorkspaceRoot`, so the vocabulary ("nx"/"native"/"moon" and
|
|
830
|
+
* the marker that decided it) has exactly one home.
|
|
831
|
+
*
|
|
832
|
+
* @param {boolean} hasMoon Whether a Moon directory marks the root.
|
|
833
|
+
* @param {boolean} hasNative Whether `archkeep.json` marks the root.
|
|
834
|
+
* @param {string|null} moonMarker Which Moon directory is present.
|
|
835
|
+
* @returns {{provider: "nx" | "moon" | "native", marker: string}}
|
|
836
|
+
*/
|
|
837
|
+
function workspaceNames(hasMoon, hasNative, moonMarker) {
|
|
838
|
+
return {
|
|
839
|
+
provider: hasMoon ? "moon" : hasNative ? "native" : "nx",
|
|
840
|
+
marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* The workspace identity of `root` — which project model governs it and which
|
|
846
|
+
* marker decided that — without reading one source file or building one graph.
|
|
847
|
+
* The single-project-model gate runs here exactly as it does in
|
|
848
|
+
* `resolveCommandContext`: both markers present is refused here for the same
|
|
849
|
+
* reason it is refused there, because a caller about to describe this
|
|
850
|
+
* workspace must not name a model the full read would have refused.
|
|
851
|
+
*
|
|
852
|
+
* @param {string} root Absolute path to the workspace root.
|
|
853
|
+
* @returns {{provider: "nx" | "moon" | "native", marker: string}}
|
|
854
|
+
* @throws {Error} when more than one project-model marker is present
|
|
855
|
+
* (`requireSingleProjectModel`).
|
|
856
|
+
*/
|
|
857
|
+
export function describeWorkspaceRoot(root) {
|
|
858
|
+
const { hasNative, moonMarker } = requireSingleProjectModel(root);
|
|
859
|
+
return workspaceNames(moonMarker !== null, hasNative, moonMarker);
|
|
860
|
+
}
|
|
861
|
+
|
|
824
862
|
// Re-exported so a caller that only needs "does this tree look like a
|
|
825
863
|
// workspace at all" (`../../cli.mjs`'s `optionsForUsage`) is not forced to
|
|
826
864
|
// duplicate the marker check a second time; `DEFAULT_OPTIONS` rides along for
|