@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
package/src/commands/history.mjs
CHANGED
|
@@ -251,6 +251,135 @@ export function nextSequence(read) {
|
|
|
251
251
|
return String(max + 1).padStart(width, "0");
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Classifies ONE transition — the record `computeEvolution` pushes for the
|
|
256
|
+
* consecutive pair `(from, to)` — together with the raw metadata comparison it
|
|
257
|
+
* was decided over. Split out of `computeEvolution` so a second consumer
|
|
258
|
+
* (`./trajectory.mjs`) aggregates the SAME classification instead of growing a
|
|
259
|
+
* second copy of it: the signals, the disclosure notes and the three-state
|
|
260
|
+
* metadata facts are decided here once (`../README.md`'s single-home rule),
|
|
261
|
+
* and both commands read them from this one place.
|
|
262
|
+
*
|
|
263
|
+
* The returned `record` is exactly what lands in `history`'s envelope. The
|
|
264
|
+
* returned `meta` is the untouched `compareSnapshotMetadata` result — the
|
|
265
|
+
* per-side facts (`policyOneSided`, `provenanceOneSided`, `crossRepo`,
|
|
266
|
+
* `dirtyBaseline`, `dirtyHead`) that the record's `notes[]` render as prose.
|
|
267
|
+
* An aggregator that needs to COUNT those facts rather than print them reads
|
|
268
|
+
* `meta`; parsing `notes` strings would be a second copy of the decision
|
|
269
|
+
* wearing a parser's name.
|
|
270
|
+
*
|
|
271
|
+
* @param {{name: string, path: string, envelope: object, id: string}} from
|
|
272
|
+
* @param {{name: string, path: string, envelope: object, id: string}} to
|
|
273
|
+
* @returns {{record: {from: string, to: string, architectureChanged: boolean,
|
|
274
|
+
* changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
|
|
275
|
+
* codeDrift: boolean, notes: string[]},
|
|
276
|
+
* meta: object}} `meta` is `compareSnapshotMetadata`'s result.
|
|
277
|
+
*/
|
|
278
|
+
export function classifyTransition(from, to) {
|
|
279
|
+
const meta = compareSnapshotMetadata({
|
|
280
|
+
baselineProvider: from.envelope.workspace.provider,
|
|
281
|
+
headProvider: to.envelope.workspace.provider,
|
|
282
|
+
baselineProvenance: from.envelope.workspace.provenance,
|
|
283
|
+
headProvenance: to.envelope.workspace.provenance,
|
|
284
|
+
baselineFingerprint: from.envelope.result.policy?.fingerprint ?? null,
|
|
285
|
+
headFingerprint: to.envelope.result.policy?.fingerprint ?? null,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const notes = [];
|
|
289
|
+
if (meta.policyChanged === true) {
|
|
290
|
+
// A policy change is disclosed the way `diff` discloses it — a fact
|
|
291
|
+
// about how the transition must be interpreted, not a structural
|
|
292
|
+
// change and not a refusal.
|
|
293
|
+
notes.push(
|
|
294
|
+
"policy (the declared architectural intent) changed between these snapshots — " +
|
|
295
|
+
"the boundary law differs even though the graph may not",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (meta.providerChanged) {
|
|
299
|
+
notes.push(
|
|
300
|
+
`provider changed (${from.envelope.workspace.provider} → ${to.envelope.workspace.provider}) — ` +
|
|
301
|
+
"structural differences may be provider-artefacts rather than real architectural changes",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (meta.crossRepo) {
|
|
305
|
+
notes.push("provenance remotes differ — these snapshots may be from unrelated repositories");
|
|
306
|
+
}
|
|
307
|
+
// The one-sided cases are the silent direction: a fingerprint or
|
|
308
|
+
// provenance on one snapshot and not the other cannot be asserted "the
|
|
309
|
+
// same", so it is disclosed rather than read as unchanged.
|
|
310
|
+
if (meta.policyOneSided) {
|
|
311
|
+
notes.push(
|
|
312
|
+
"policy (the declared architectural intent) could not be compared — one snapshot " +
|
|
313
|
+
"records the boundary law and the other does not",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (meta.provenanceOneSided) {
|
|
317
|
+
notes.push(
|
|
318
|
+
"repository provenance could not be compared — one snapshot records its origin and the other does not",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
// A snapshot taken from a dirty tree is not a reproducible claim about the
|
|
322
|
+
// commit it names, so the transition says which side came from one rather
|
|
323
|
+
// than reading it as a claim about committed history.
|
|
324
|
+
if (meta.dirtyBaseline) {
|
|
325
|
+
const commit = from.envelope.workspace.provenance?.commit;
|
|
326
|
+
notes.push(
|
|
327
|
+
"the baseline snapshot was captured from an uncommitted (dirty) tree — its architecture " +
|
|
328
|
+
`is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (meta.dirtyHead) {
|
|
332
|
+
const commit = to.envelope.workspace.provenance?.commit;
|
|
333
|
+
notes.push(
|
|
334
|
+
"the head snapshot was captured from an uncommitted (dirty) tree — its architecture " +
|
|
335
|
+
`is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const diff = computeDiff(
|
|
340
|
+
{
|
|
341
|
+
projects: from.envelope.result.projects,
|
|
342
|
+
dependencies: from.envelope.result.dependencies,
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
projects: to.envelope.result.projects,
|
|
346
|
+
dependencies: to.envelope.result.dependencies,
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
const architectureChanged =
|
|
350
|
+
diff.addedProjects.length > 0 ||
|
|
351
|
+
diff.removedProjects.length > 0 ||
|
|
352
|
+
diff.changedProjects.length > 0 ||
|
|
353
|
+
diff.addedEdges.length > 0 ||
|
|
354
|
+
diff.removedEdges.length > 0;
|
|
355
|
+
|
|
356
|
+
// Code drift is a disclosure, so it is only asserted when every signal
|
|
357
|
+
// that could refute it is verifiable and unchanged: the architecture did
|
|
358
|
+
// not move, the policy was actually compared and did not change, and
|
|
359
|
+
// provenance advanced. A `null` policyChanged (one-sided, or neither
|
|
360
|
+
// snapshot carries a fingerprint) is "could not be compared", not "the
|
|
361
|
+
// same" — asserting code drift on an unverifiable policy would report a
|
|
362
|
+
// clean transition where the tool cannot look.
|
|
363
|
+
const codeDrift =
|
|
364
|
+
!architectureChanged && meta.policyChanged === false && meta.provenanceChanged === true;
|
|
365
|
+
|
|
366
|
+
const record = {
|
|
367
|
+
from: from.name,
|
|
368
|
+
to: to.name,
|
|
369
|
+
architectureChanged,
|
|
370
|
+
// A provider change is rendered with an empty diff (no graph change on
|
|
371
|
+
// top of a carrier change), a policy-only transition with null — so a
|
|
372
|
+
// consumer can tell "the carrier changed" from "only the record's
|
|
373
|
+
// interpretation changed".
|
|
374
|
+
changes: architectureChanged || meta.providerChanged ? diff : null,
|
|
375
|
+
policyChanged: meta.policyChanged,
|
|
376
|
+
providerChanged: meta.providerChanged,
|
|
377
|
+
codeDrift,
|
|
378
|
+
notes,
|
|
379
|
+
};
|
|
380
|
+
return { record, meta };
|
|
381
|
+
}
|
|
382
|
+
|
|
254
383
|
/**
|
|
255
384
|
* Computes the evolution record from a list of snapshots: history order,
|
|
256
385
|
* each snapshot's identity, and the classified transition from each to the
|
|
@@ -274,109 +403,7 @@ export function computeEvolution(files) {
|
|
|
274
403
|
const transitions = [];
|
|
275
404
|
|
|
276
405
|
for (let i = 0; i + 1 < files.length; i++) {
|
|
277
|
-
|
|
278
|
-
const to = files[i + 1];
|
|
279
|
-
const meta = compareSnapshotMetadata({
|
|
280
|
-
baselineProvider: from.envelope.workspace.provider,
|
|
281
|
-
headProvider: to.envelope.workspace.provider,
|
|
282
|
-
baselineProvenance: from.envelope.workspace.provenance,
|
|
283
|
-
headProvenance: to.envelope.workspace.provenance,
|
|
284
|
-
baselineFingerprint: from.envelope.result.policy?.fingerprint ?? null,
|
|
285
|
-
headFingerprint: to.envelope.result.policy?.fingerprint ?? null,
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
const notes = [];
|
|
289
|
-
if (meta.policyChanged === true) {
|
|
290
|
-
// A policy change is disclosed the way `diff` discloses it — a fact
|
|
291
|
-
// about how the transition must be interpreted, not a structural
|
|
292
|
-
// change and not a refusal.
|
|
293
|
-
notes.push(
|
|
294
|
-
"policy (the declared architectural intent) changed between these snapshots — " +
|
|
295
|
-
"the boundary law differs even though the graph may not",
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
if (meta.providerChanged) {
|
|
299
|
-
notes.push(
|
|
300
|
-
`provider changed (${from.envelope.workspace.provider} → ${to.envelope.workspace.provider}) — ` +
|
|
301
|
-
"structural differences may be provider-artefacts rather than real architectural changes",
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
if (meta.crossRepo) {
|
|
305
|
-
notes.push("provenance remotes differ — these snapshots may be from unrelated repositories");
|
|
306
|
-
}
|
|
307
|
-
// The one-sided cases are the silent direction: a fingerprint or
|
|
308
|
-
// provenance on one snapshot and not the other cannot be asserted "the
|
|
309
|
-
// same", so it is disclosed rather than read as unchanged.
|
|
310
|
-
if (meta.policyOneSided) {
|
|
311
|
-
notes.push(
|
|
312
|
-
"policy (the declared architectural intent) could not be compared — one snapshot " +
|
|
313
|
-
"records the boundary law and the other does not",
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
if (meta.provenanceOneSided) {
|
|
317
|
-
notes.push(
|
|
318
|
-
"repository provenance could not be compared — one snapshot records its origin and the other does not",
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
// A snapshot taken from a dirty tree is not a reproducible claim about the
|
|
322
|
-
// commit it names, so the transition says which side came from one rather
|
|
323
|
-
// than reading it as a claim about committed history.
|
|
324
|
-
if (meta.dirtyBaseline) {
|
|
325
|
-
const commit = from.envelope.workspace.provenance?.commit;
|
|
326
|
-
notes.push(
|
|
327
|
-
"the baseline snapshot was captured from an uncommitted (dirty) tree — its architecture " +
|
|
328
|
-
`is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
if (meta.dirtyHead) {
|
|
332
|
-
const commit = to.envelope.workspace.provenance?.commit;
|
|
333
|
-
notes.push(
|
|
334
|
-
"the head snapshot was captured from an uncommitted (dirty) tree — its architecture " +
|
|
335
|
-
`is a claim about uncommitted state${typeof commit === "string" ? `, not about commit '${commit}'` : ""}`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const diff = computeDiff(
|
|
340
|
-
{
|
|
341
|
-
projects: from.envelope.result.projects,
|
|
342
|
-
dependencies: from.envelope.result.dependencies,
|
|
343
|
-
},
|
|
344
|
-
{
|
|
345
|
-
projects: to.envelope.result.projects,
|
|
346
|
-
dependencies: to.envelope.result.dependencies,
|
|
347
|
-
},
|
|
348
|
-
);
|
|
349
|
-
const architectureChanged =
|
|
350
|
-
diff.addedProjects.length > 0 ||
|
|
351
|
-
diff.removedProjects.length > 0 ||
|
|
352
|
-
diff.changedProjects.length > 0 ||
|
|
353
|
-
diff.addedEdges.length > 0 ||
|
|
354
|
-
diff.removedEdges.length > 0;
|
|
355
|
-
|
|
356
|
-
// Code drift is a disclosure, so it is only asserted when every signal
|
|
357
|
-
// that could refute it is verifiable and unchanged: the architecture did
|
|
358
|
-
// not move, the policy was actually compared and did not change, and
|
|
359
|
-
// provenance advanced. A `null` policyChanged (one-sided, or neither
|
|
360
|
-
// snapshot carries a fingerprint) is "could not be compared", not "the
|
|
361
|
-
// same" — asserting code drift on an unverifiable policy would report a
|
|
362
|
-
// clean transition where the tool cannot look.
|
|
363
|
-
const codeDrift =
|
|
364
|
-
!architectureChanged && meta.policyChanged === false && meta.provenanceChanged === true;
|
|
365
|
-
|
|
366
|
-
transitions.push({
|
|
367
|
-
from: from.name,
|
|
368
|
-
to: to.name,
|
|
369
|
-
architectureChanged,
|
|
370
|
-
// A provider change is rendered with an empty diff (no graph change on
|
|
371
|
-
// top of a carrier change), a policy-only transition with null — so a
|
|
372
|
-
// consumer can tell "the carrier changed" from "only the record's
|
|
373
|
-
// interpretation changed".
|
|
374
|
-
changes: architectureChanged || meta.providerChanged ? diff : null,
|
|
375
|
-
policyChanged: meta.policyChanged,
|
|
376
|
-
providerChanged: meta.providerChanged,
|
|
377
|
-
codeDrift,
|
|
378
|
-
notes,
|
|
379
|
-
});
|
|
406
|
+
transitions.push(classifyTransition(files[i], files[i + 1]).record);
|
|
380
407
|
}
|
|
381
408
|
|
|
382
409
|
return { snapshots, transitions };
|
package/src/commands/policy.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* below argues the order, each arm, and what `profile`/`source` name.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
11
12
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
12
13
|
|
|
13
14
|
import { containmentViolation } from "../containment.mjs";
|
|
@@ -118,6 +119,62 @@ export async function resolvePolicy(options, commandContext, cwd) {
|
|
|
118
119
|
return resolved;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The policy load for a command that DESCRIBES the workspace rather than
|
|
124
|
+
* judging it — `graph`'s own arm, held here beside the ladder it wraps so
|
|
125
|
+
* every future descriptive surface (`./graph.mjs`'s MCP caller among them)
|
|
126
|
+
* answers from one copy of the "absent default law" decision rather than
|
|
127
|
+
* re-deriving it.
|
|
128
|
+
*
|
|
129
|
+
* What is skipped is the load of a file that is NOT THERE. A boundary config
|
|
130
|
+
* that exists and will not load still fails the run, because an absent law
|
|
131
|
+
* and a broken one must not report alike; a `--config`, a profile, and an
|
|
132
|
+
* inline `archkeep.json` policy are explicit declarations and stay loud.
|
|
133
|
+
* Every command that JUDGES against the law keeps loading it unconditionally
|
|
134
|
+
* through `resolvePolicy` — making it optional for those would turn a missing
|
|
135
|
+
* file into a silent no-law run.
|
|
136
|
+
*
|
|
137
|
+
* `boundaryConfigDeclared` is what keeps this guard to the un-overridden
|
|
138
|
+
* default, and it is load-bearing rather than belt-and-braces. The name
|
|
139
|
+
* alone cannot answer it: `commandContext.options.boundaryConfig` is a
|
|
140
|
+
* string BOTH when it came from `../options.mjs`'s `DEFAULT_OPTIONS` and
|
|
141
|
+
* when the consumer WROTE it into `nx.json`'s plugin options or
|
|
142
|
+
* `archkeep.json`, and a workspace is free to declare the convention
|
|
143
|
+
* filename itself, so comparing against the default would still read a
|
|
144
|
+
* deliberate declaration as an assumption. Measured on a committed native
|
|
145
|
+
* tree whose `archkeep.json` declares a `boundaryConfig` that file does not
|
|
146
|
+
* contain: skipping on name alone made `graph` exit 0 with no `policy` field
|
|
147
|
+
* — byte-identical to a workspace that never had a law — where the same tree
|
|
148
|
+
* with that file present but unparseable exited 3. A law someone named and
|
|
149
|
+
* then renamed or deleted is exactly the case that must stay loud, so the
|
|
150
|
+
* provenance survives the options layer instead
|
|
151
|
+
* (`../options.mjs`'s `resolveOptions`,
|
|
152
|
+
* `../providers/native/model.mjs`'s `normalizeNativeModel`, and
|
|
153
|
+
* `./context.mjs`'s three branches carry it; Moon answers `false` because it
|
|
154
|
+
* has no table to declare one in).
|
|
155
|
+
*
|
|
156
|
+
* @param {{config: string|null}} options The command's own parsed flags.
|
|
157
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
158
|
+
* @param {string} cwd The process's working directory a relative `--config`
|
|
159
|
+
* resolves against.
|
|
160
|
+
* @returns {Promise<{config: object|null, profile: string|null, source: string|null}>}
|
|
161
|
+
* `null` config only on the absent-un-overridden-default arm.
|
|
162
|
+
* @throws {Error} through `resolvePolicy` for every law that is named but
|
|
163
|
+
* cannot be loaded.
|
|
164
|
+
*/
|
|
165
|
+
export async function resolveDescribedPolicy(options, commandContext, cwd) {
|
|
166
|
+
const workspaceDefault =
|
|
167
|
+
!options.config &&
|
|
168
|
+
!hasProfiles(commandContext.options) &&
|
|
169
|
+
commandContext.options.boundaryConfigDeclared === false &&
|
|
170
|
+
typeof commandContext.options.boundaryConfig === "string"
|
|
171
|
+
? resolve(commandContext.root, commandContext.options.boundaryConfig)
|
|
172
|
+
: null;
|
|
173
|
+
return workspaceDefault !== null && !existsSync(workspaceDefault)
|
|
174
|
+
? { config: null, profile: null, source: null }
|
|
175
|
+
: resolvePolicy(options, commandContext, cwd);
|
|
176
|
+
}
|
|
177
|
+
|
|
121
178
|
/**
|
|
122
179
|
* The four arms of the ladder, unguarded — `resolvePolicy` above wraps this
|
|
123
180
|
* with the one post-resolution refusal that needs both the provider and the
|