@ecoma-io/archkeep 0.20.1 → 0.22.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/cli.mjs +156 -66
- package/package.json +1 -1
- package/src/analysis/contract.md +32 -5
- package/src/analysis/source-util.mjs +107 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/commands/change.mjs +59 -28
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +708 -0
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +184 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +30 -10
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +691 -0
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +14 -5
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +29 -15
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +18 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +363 -198
- package/src/commands/scenario.mjs +32 -21
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +62 -9
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/intent/intent-manifest.json +83 -39
- package/src/report/json.mjs +32 -5
- package/src/report/provenance-text.mjs +30 -7
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/workspace.mjs +126 -2
|
@@ -80,6 +80,7 @@ import { debtChangeDiff, debtFactId, driftFactOf } from "../governance/debt-ledg
|
|
|
80
80
|
import { computeAffectedDecisions } from "../governance/decision-lineage.mjs";
|
|
81
81
|
import {
|
|
82
82
|
classifyEvolution,
|
|
83
|
+
edgeEvolutionIdentity,
|
|
83
84
|
eventDedupeKey,
|
|
84
85
|
eventId,
|
|
85
86
|
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
@@ -939,23 +940,24 @@ function buildEvolutionSummary(comparisons) {
|
|
|
939
940
|
comparisons.flatMap((c) => c.observed.projects.changed.map((p) => p.name ?? p)),
|
|
940
941
|
),
|
|
941
942
|
},
|
|
943
|
+
// The identity string is the ONE spelling `edgeEvolutionIdentity` owns —
|
|
944
|
+
// the same spelling `affected.boundaries` and every stored event carry.
|
|
945
|
+
// An edge without a complete triple has no identity to name, so it is
|
|
946
|
+
// dropped from the union and counted into `unnamedEdges` rather than
|
|
947
|
+
// leaking an object serialization into a field of identity strings.
|
|
942
948
|
edges: {
|
|
943
949
|
added: unique(
|
|
944
950
|
comparisons.flatMap((c) =>
|
|
945
|
-
c.observed.edges.added
|
|
946
|
-
e.source && e.target && e.type
|
|
947
|
-
|
|
948
|
-
: JSON.stringify(e),
|
|
949
|
-
),
|
|
951
|
+
c.observed.edges.added
|
|
952
|
+
.filter((e) => e.source && e.target && e.type)
|
|
953
|
+
.map((e) => edgeEvolutionIdentity(e)),
|
|
950
954
|
),
|
|
951
955
|
),
|
|
952
956
|
removed: unique(
|
|
953
957
|
comparisons.flatMap((c) =>
|
|
954
|
-
c.observed.edges.removed
|
|
955
|
-
e.source && e.target && e.type
|
|
956
|
-
|
|
957
|
-
: JSON.stringify(e),
|
|
958
|
-
),
|
|
958
|
+
c.observed.edges.removed
|
|
959
|
+
.filter((e) => e.source && e.target && e.type)
|
|
960
|
+
.map((e) => edgeEvolutionIdentity(e)),
|
|
959
961
|
),
|
|
960
962
|
),
|
|
961
963
|
},
|
|
@@ -983,6 +985,21 @@ function buildEvolutionSummary(comparisons) {
|
|
|
983
985
|
};
|
|
984
986
|
|
|
985
987
|
const notes = unique(comparisons.flatMap((c) => c.notes ?? []));
|
|
988
|
+
const unnamedEdges = comparisons.reduce(
|
|
989
|
+
(count, c) =>
|
|
990
|
+
count +
|
|
991
|
+
[...c.observed.edges.added, ...c.observed.edges.removed].filter(
|
|
992
|
+
(e) => !(e.source && e.target && e.type),
|
|
993
|
+
).length,
|
|
994
|
+
0,
|
|
995
|
+
);
|
|
996
|
+
if (unnamedEdges > 0) {
|
|
997
|
+
// The house shape for "we could not name it": a note naming the gap,
|
|
998
|
+
// never a silent drop dressed as a clean union.
|
|
999
|
+
notes.push(
|
|
1000
|
+
`${unnamedEdges} changed edge(s) carry no complete identity and are not named in observed.edges`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
986
1003
|
return {
|
|
987
1004
|
transitions: comparisons.length,
|
|
988
1005
|
disposition,
|
package/src/commands/explain.mjs
CHANGED
|
@@ -79,7 +79,11 @@
|
|
|
79
79
|
* was: the field, and its rendered lines, exist only when the comparison was
|
|
80
80
|
* requested.
|
|
81
81
|
*/
|
|
82
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
blindSpotRows,
|
|
84
|
+
isWholeFileFailure,
|
|
85
|
+
unresolvableLiteralCount,
|
|
86
|
+
} from "../analysis/source-util.mjs";
|
|
83
87
|
import { UsageError } from "../errors.mjs";
|
|
84
88
|
import { evaluate } from "../rules/index.mjs";
|
|
85
89
|
import { findConstraintsFor } from "../rules/tags.mjs";
|
|
@@ -369,7 +373,13 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
369
373
|
.filter(isWholeFileFailure)
|
|
370
374
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
371
375
|
|
|
372
|
-
|
|
376
|
+
// An unresolvable site was seen but never judged (#595): the graph is
|
|
377
|
+
// missing whatever edge that site would have drawn, and rules that judge
|
|
378
|
+
// the whole graph (circularity, lazy loading) would answer over a gap. The
|
|
379
|
+
// explanation still reports — status no-verdict — naming the site in
|
|
380
|
+
// `coverage.blindSpots`, the same contract `graph`/`discover` run.
|
|
381
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
382
|
+
const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
|
|
373
383
|
const status = complete ? "ok" : "no-verdict";
|
|
374
384
|
|
|
375
385
|
// Find the import record at this site.
|
|
@@ -422,14 +432,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
422
432
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
423
433
|
imports: commandContext.analysis.imports.length,
|
|
424
434
|
notAnalyzed,
|
|
425
|
-
blindSpots: commandContext.analysis.failures
|
|
426
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
427
|
-
.map(({ sourceFile, line, column, reason }) => ({
|
|
428
|
-
file: sourceFile,
|
|
429
|
-
line,
|
|
430
|
-
column,
|
|
431
|
-
reason,
|
|
432
|
-
})),
|
|
435
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
433
436
|
notes: [],
|
|
434
437
|
};
|
|
435
438
|
|
|
@@ -576,9 +579,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
576
579
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
577
580
|
imports: commandContext.analysis.imports.length,
|
|
578
581
|
notAnalyzed,
|
|
579
|
-
blindSpots: commandContext.analysis.failures
|
|
580
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
581
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
582
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
582
583
|
notes: [],
|
|
583
584
|
};
|
|
584
585
|
|
package/src/commands/fitness.mjs
CHANGED
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
* sorted; JSON rides `canonicalizeJson`. Two runs over an unchanged tree and
|
|
46
46
|
* policy produce byte-identical text and JSON.
|
|
47
47
|
*/
|
|
48
|
-
import {
|
|
48
|
+
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
49
49
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
50
50
|
import { formatFitnessSection } from "../report/text.mjs";
|
|
51
51
|
import { resolveProvenance } from "./provenance.mjs";
|
|
52
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
52
53
|
import { driftForCheck } from "./drift.mjs";
|
|
53
54
|
import {
|
|
54
55
|
evaluateFitness,
|
|
@@ -114,9 +115,13 @@ export function declaresFitness(config) {
|
|
|
114
115
|
*
|
|
115
116
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
116
117
|
* @param {{config?: object|null}} [io] The loaded policy, injectable for tests.
|
|
117
|
-
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness
|
|
118
|
-
* report: {text: string, json: string}}>}
|
|
119
|
-
*
|
|
118
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness?: object,
|
|
119
|
+
* coverage: object, report: {text: string, json: string}}>}
|
|
120
|
+
* `status: "no-verdict"` from the coverage refusal carries no `fitness`
|
|
121
|
+
* payload — the verdict was withheld, and the envelope's `coverage` block is
|
|
122
|
+
* the whole answer (#608).
|
|
123
|
+
* @throws {Error} on every condition the header lists except the coverage one,
|
|
124
|
+
* which returns instead of throwing.
|
|
120
125
|
*/
|
|
121
126
|
export async function fitnessCommand(commandContext, io = {}) {
|
|
122
127
|
const { root, provider, marker, analysis } = commandContext;
|
|
@@ -129,18 +134,16 @@ export async function fitnessCommand(commandContext, io = {}) {
|
|
|
129
134
|
);
|
|
130
135
|
}
|
|
131
136
|
|
|
132
|
-
// A verdict over a tree it could not fully read is a guess.
|
|
133
|
-
// `
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`unanalyzed files and re-run.`,
|
|
143
|
-
);
|
|
137
|
+
// A verdict over a tree it could not fully read is a guess. Refused through
|
|
138
|
+
// the one structured contract `./coverage-verdict.mjs` builds (#608) — this
|
|
139
|
+
// gate used to check whole-file failures only and then claim
|
|
140
|
+
// `coverage.complete: true` beside `blindSpots` that could carry an unjudged
|
|
141
|
+
// site, which the envelope law refuses as a programming error. The unified
|
|
142
|
+
// completeness returns the no-verdict envelope instead, for every axis the
|
|
143
|
+
// envelope law already withholds over.
|
|
144
|
+
const completeness = coverageVerdict(commandContext);
|
|
145
|
+
if (!completeness.complete) {
|
|
146
|
+
return coverageRefusal({ command: "fitness", commandContext, what: "judging fitness" });
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
// `drift-free` judges the SAME verdict-shaped intent `check`'s fold builds —
|
|
@@ -192,9 +195,7 @@ export async function fitnessCommand(commandContext, io = {}) {
|
|
|
192
195
|
analyzedFiles: analysis.analyzed,
|
|
193
196
|
imports: analysis.imports.length,
|
|
194
197
|
notAnalyzed: [],
|
|
195
|
-
blindSpots: analysis.failures
|
|
196
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
197
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
198
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
198
199
|
notes: [],
|
|
199
200
|
};
|
|
200
201
|
|
package/src/commands/graph.mjs
CHANGED
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
blindSpotRows,
|
|
34
|
+
isWholeFileFailure,
|
|
35
|
+
unresolvableLiteralCount,
|
|
36
|
+
} from "../analysis/source-util.mjs";
|
|
33
37
|
import { canonicalizeJson } from "../canonical.mjs";
|
|
34
38
|
import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
|
|
35
39
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
@@ -222,7 +226,14 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
222
226
|
.filter(isWholeFileFailure)
|
|
223
227
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
224
228
|
|
|
225
|
-
|
|
229
|
+
// An unresolvable import site was seen but never judged (#595): the edges
|
|
230
|
+
// out of it may be missing from this snapshot, so the snapshot must not
|
|
231
|
+
// claim `complete` over it. It still reports — status no-verdict, exit 3 —
|
|
232
|
+
// naming the site in `coverage.blindSpots`, the same contract `check` runs.
|
|
233
|
+
const blindSpots = blindSpotRows(commandContext.analysis.failures);
|
|
234
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
235
|
+
|
|
236
|
+
const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
|
|
226
237
|
const status = complete ? "ok" : "no-verdict";
|
|
227
238
|
const exitCode = complete ? 0 : 3;
|
|
228
239
|
|
|
@@ -245,9 +256,7 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
245
256
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
246
257
|
imports: commandContext.analysis.imports.length,
|
|
247
258
|
notAnalyzed,
|
|
248
|
-
blindSpots
|
|
249
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
250
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
259
|
+
blindSpots,
|
|
251
260
|
notes: [],
|
|
252
261
|
};
|
|
253
262
|
|
package/src/commands/health.mjs
CHANGED
|
@@ -43,7 +43,11 @@
|
|
|
43
43
|
* It does not print, and it does not decide the process's exit code —
|
|
44
44
|
* `../../cli.mjs` owns those (`./README.md`).
|
|
45
45
|
*/
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
blindSpotRows,
|
|
48
|
+
isWholeFileFailure,
|
|
49
|
+
unresolvableLiteralCount,
|
|
50
|
+
} from "../analysis/source-util.mjs";
|
|
47
51
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
48
52
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
49
53
|
import { formatHealthReport } from "../report/health-text.mjs";
|
|
@@ -108,7 +112,12 @@ export function healthCommand(commandContext, io = {}) {
|
|
|
108
112
|
const edges = buildDependencies(graph.dependencies);
|
|
109
113
|
|
|
110
114
|
// The run's coverage facts, the same shape every command's envelope carries.
|
|
111
|
-
|
|
115
|
+
// An unresolvable site is a fact the run saw but never judged (#595) —
|
|
116
|
+
// metrics measured over it would read precision the run does not have,
|
|
117
|
+
// so it defeats file completeness the way a whole-file failure does.
|
|
118
|
+
const fileComplete =
|
|
119
|
+
analysis.failures.filter(isWholeFileFailure).length === 0 &&
|
|
120
|
+
unresolvableLiteralCount(analysis.failures) === 0;
|
|
112
121
|
// The graph is complete only when the files are AND the graph actually sees
|
|
113
122
|
// every polyglot edge — an Nx workspace with an unregistered plugin carries
|
|
114
123
|
// a graph with no Go/Rust/Python edges, which `graph`/`impact` refuse and
|
|
@@ -123,9 +132,7 @@ export function healthCommand(commandContext, io = {}) {
|
|
|
123
132
|
notAnalyzed: analysis.failures
|
|
124
133
|
.filter(isWholeFileFailure)
|
|
125
134
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
|
|
126
|
-
blindSpots: analysis.failures
|
|
127
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
128
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
135
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
129
136
|
notes: graphComplete
|
|
130
137
|
? []
|
|
131
138
|
: [
|
package/src/commands/history.mjs
CHANGED
|
@@ -74,12 +74,16 @@ import { createHash } from "node:crypto";
|
|
|
74
74
|
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
75
75
|
import { basename, join, resolve } from "node:path";
|
|
76
76
|
|
|
77
|
-
import {
|
|
77
|
+
import {
|
|
78
|
+
blindSpotRows,
|
|
79
|
+
isWholeFileFailure,
|
|
80
|
+
unresolvableLiteralCount,
|
|
81
|
+
} from "../analysis/source-util.mjs";
|
|
78
82
|
import { containmentViolation } from "../containment.mjs";
|
|
79
83
|
import { classifyEvolution } from "../governance/evolution-event.mjs";
|
|
80
84
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
81
85
|
import { formatHistoryReport } from "../report/history-text.mjs";
|
|
82
|
-
import { computeDiff,
|
|
86
|
+
import { computeDiff, parseBaseline } from "./diff.mjs";
|
|
83
87
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
84
88
|
import { resolveProvenance } from "./provenance.mjs";
|
|
85
89
|
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
@@ -397,8 +401,12 @@ export function classifyTransition(from, to) {
|
|
|
397
401
|
changed: diff.changedProjects.map((project) => project.name),
|
|
398
402
|
},
|
|
399
403
|
edges: {
|
|
400
|
-
|
|
401
|
-
|
|
404
|
+
// The raw triples — `classifyEvolution` maps them through its own
|
|
405
|
+
// identity spelling (`edgeEvolutionIdentity`), so `affected.boundaries`
|
|
406
|
+
// carries the canonical strings every other event surface uses, not
|
|
407
|
+
// this module's diff-internal key spelling.
|
|
408
|
+
added: diff.addedEdges,
|
|
409
|
+
removed: diff.removedEdges,
|
|
402
410
|
},
|
|
403
411
|
policyChanged: meta.policyChanged,
|
|
404
412
|
policyOneSided: meta.policyOneSided,
|
|
@@ -554,10 +562,23 @@ export function historyCommand(
|
|
|
554
562
|
const notAnalyzed = commandContext.analysis.failures
|
|
555
563
|
.filter(isWholeFileFailure)
|
|
556
564
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
557
|
-
|
|
565
|
+
|
|
566
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
567
|
+
if (notAnalyzed.length > 0 || blindSpotCount > 0) {
|
|
558
568
|
throw new Error(
|
|
559
|
-
`archkeep: the head graph has incomplete coverage —
|
|
560
|
-
|
|
569
|
+
`archkeep: the head graph has incomplete coverage — ` +
|
|
570
|
+
[
|
|
571
|
+
notAnalyzed.length > 0
|
|
572
|
+
? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
|
|
573
|
+
: null,
|
|
574
|
+
blindSpotCount > 0
|
|
575
|
+
? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
|
|
576
|
+
: null,
|
|
577
|
+
]
|
|
578
|
+
.filter(Boolean)
|
|
579
|
+
.join(", ") +
|
|
580
|
+
`, so
|
|
581
|
+
a captured snapshot ` +
|
|
561
582
|
`would under-represent the real architecture. Fix the unanalyzed files and re-run.`,
|
|
562
583
|
);
|
|
563
584
|
}
|
|
@@ -608,14 +629,7 @@ export function historyCommand(
|
|
|
608
629
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
609
630
|
imports: commandContext.analysis.imports.length,
|
|
610
631
|
notAnalyzed: [],
|
|
611
|
-
blindSpots: commandContext.analysis.failures
|
|
612
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
613
|
-
.map(({ sourceFile, line, column, reason }) => ({
|
|
614
|
-
file: sourceFile,
|
|
615
|
-
line,
|
|
616
|
-
column,
|
|
617
|
-
reason,
|
|
618
|
-
})),
|
|
632
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
619
633
|
notes: [],
|
|
620
634
|
},
|
|
621
635
|
result: { ...head, policy: headPolicy ?? undefined },
|