@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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { resolveProvenance } from "./provenance.mjs";
|
|
13
13
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
14
|
-
import {
|
|
14
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
15
15
|
import { evaluateScenario, parseScenarioInput } from "./scenario-evaluation.mjs";
|
|
16
16
|
export { parseScenarioInput } from "./scenario-evaluation.mjs";
|
|
17
17
|
|
|
@@ -23,7 +23,10 @@ export { parseScenarioInput } from "./scenario-evaluation.mjs";
|
|
|
23
23
|
* @param {string} scenarioJson The scenario description as JSON.
|
|
24
24
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
25
25
|
* @param {object} [config] The loaded boundary config.
|
|
26
|
-
* @returns {{status:
|
|
26
|
+
* @returns {{status: "ok"|"no-verdict", scenario?: object, coverage: object,
|
|
27
|
+
* report: {text: string, json: string}}} `scenario` is absent under
|
|
28
|
+
* `status: "no-verdict"` — the coverage refusal (#608) withholds the
|
|
29
|
+
* evaluation, and the envelope's `coverage` block is the whole answer.
|
|
27
30
|
*/
|
|
28
31
|
export function scenarioCommand(projectName, scenarioJson, commandContext, config = null) {
|
|
29
32
|
const { root, provider, marker, graph, pluginGap } = commandContext;
|
|
@@ -44,31 +47,24 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
44
47
|
// Parse the scenario input
|
|
45
48
|
const scenarioInput = parseScenarioInput(scenarioJson);
|
|
46
49
|
|
|
47
|
-
// Check coverage
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
throw new Error(
|
|
54
|
-
`archkeep: the graph has incomplete coverage — ${notAnalyzed.length} file` +
|
|
55
|
-
`${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the scenario may ` +
|
|
56
|
-
`under-represent the real architecture. Fix the unanalyzed files and re-run.`,
|
|
57
|
-
);
|
|
50
|
+
// Check coverage — refused through the one structured contract
|
|
51
|
+
// `./coverage-verdict.mjs` builds (#608): the evaluation is withheld
|
|
52
|
+
// in-band, where a parser and `--output` can read it.
|
|
53
|
+
const completeness = coverageVerdict(commandContext);
|
|
54
|
+
if (!completeness.complete) {
|
|
55
|
+
return coverageRefusal({ command: "scenario", commandContext, what: "evaluating a scenario" });
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
// Evaluate
|
|
61
59
|
const scenario = evaluateScenario(projectName, commandContext, scenarioInput, config);
|
|
62
60
|
|
|
63
61
|
const coverage = {
|
|
64
|
-
complete:
|
|
62
|
+
complete: true,
|
|
65
63
|
projects: Object.keys(graph.nodes).length,
|
|
66
64
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
67
65
|
imports: commandContext.analysis.imports.length,
|
|
68
66
|
notAnalyzed: [],
|
|
69
|
-
blindSpots:
|
|
70
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
71
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
67
|
+
blindSpots: completeness.blindSpots,
|
|
72
68
|
notes: [
|
|
73
69
|
"scenario evaluation is virtual and not authoritative — run `check` for the real verdict",
|
|
74
70
|
"per-edge verdicts cover only depConstraints (3 of 15 violation types)",
|
|
@@ -81,6 +77,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
81
77
|
virtual: scenario.virtual,
|
|
82
78
|
notAuthoritative: scenario.notAuthoritative,
|
|
83
79
|
complete: scenario.complete,
|
|
80
|
+
completeness: scenario.completeness,
|
|
84
81
|
project: scenario.project,
|
|
85
82
|
base: scenario.base,
|
|
86
83
|
changes: scenario.changes,
|
|
@@ -102,7 +99,7 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
102
99
|
result,
|
|
103
100
|
});
|
|
104
101
|
|
|
105
|
-
const text = formatScenarioReport(scenario);
|
|
102
|
+
const text = formatScenarioReport(scenario, coverage);
|
|
106
103
|
|
|
107
104
|
return {
|
|
108
105
|
status: "ok",
|
|
@@ -118,15 +115,29 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
|
|
|
118
115
|
/**
|
|
119
116
|
* Formats a scenario evaluation as terminal text.
|
|
120
117
|
*
|
|
118
|
+
* The coverage line rides the same `coverageIncompleteReasons` clauses every
|
|
119
|
+
* other text face uses (`../verdict.mjs`), so a terminal reader is told how
|
|
120
|
+
* much of the tree the comparison inspected, in the one wording the JSON
|
|
121
|
+
* envelope's status also speaks (#609).
|
|
122
|
+
*
|
|
121
123
|
* @param {object} scenario The scenario evaluation result.
|
|
124
|
+
* @param {object} coverage The run's coverage block.
|
|
122
125
|
* @returns {string}
|
|
123
126
|
*/
|
|
124
|
-
function formatScenarioReport(scenario) {
|
|
127
|
+
function formatScenarioReport(scenario, coverage) {
|
|
125
128
|
const lines = [];
|
|
126
129
|
|
|
127
130
|
lines.push(`Scenario evaluation for "${scenario.project}"`);
|
|
128
131
|
lines.push(`${"=".repeat(50)}`);
|
|
129
132
|
lines.push(`Virtual: ${scenario.virtual} | Not authoritative: ${scenario.notAuthoritative}`);
|
|
133
|
+
// The "how much was inspected" line, derived from the same coverage block
|
|
134
|
+
// the envelope carries — never re-counted here.
|
|
135
|
+
lines.push(
|
|
136
|
+
`Coverage: ${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
137
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
138
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}` +
|
|
139
|
+
(coverage.complete ? "" : " — coverage incomplete"),
|
|
140
|
+
);
|
|
130
141
|
lines.push("");
|
|
131
142
|
|
|
132
143
|
if (scenario.changes.length > 0) {
|
|
@@ -167,10 +178,10 @@ function formatScenarioReport(scenario) {
|
|
|
167
178
|
if (delta.dependentsAdded.length === 0 && delta.dependentsRemoved.length === 0) {
|
|
168
179
|
lines.push(" No change to dependent set");
|
|
169
180
|
}
|
|
170
|
-
if (delta.constraintsChanged) {
|
|
181
|
+
if (delta.constraintsChanged && delta.constraintsChanged.status === "changed") {
|
|
171
182
|
lines.push(" Constraint impact: CHANGED");
|
|
172
183
|
}
|
|
173
|
-
if (delta.decisionsChanged) {
|
|
184
|
+
if (delta.decisionsChanged && delta.decisionsChanged.status === "changed") {
|
|
174
185
|
lines.push(" Decision impact: CHANGED");
|
|
175
186
|
}
|
|
176
187
|
lines.push("");
|
package/src/commands/waivers.mjs
CHANGED
|
@@ -37,10 +37,11 @@
|
|
|
37
37
|
* `now` and the output is reproducible byte-for-byte. Defaults to the wall
|
|
38
38
|
* clock, the same injection `evaluate` uses for waiver expiry.
|
|
39
39
|
*/
|
|
40
|
-
import {
|
|
40
|
+
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
41
41
|
import { suppressionCovers } from "../config.mjs";
|
|
42
42
|
import { referenceTime } from "../governance/clock.mjs";
|
|
43
43
|
import { isWaiver, remainingMs, waiverStatus } from "../governance/waiver.mjs";
|
|
44
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
44
45
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
45
46
|
import { formatWaiversReport } from "../report/waivers-text.mjs";
|
|
46
47
|
import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
|
|
@@ -150,10 +151,13 @@ export function computeWaivers(suppressions, rawViolations, now = referenceTime(
|
|
|
150
151
|
* clock, and — from `cli.mjs`'s `runWaivers` — the workspace-relative path
|
|
151
152
|
* the run's law actually resolved from, so the `coverage.unowned` matching
|
|
152
153
|
* below subtracts the same configuration files `check` subtracts.
|
|
153
|
-
* @returns {Promise<{status: "ok", waivers
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* @returns {Promise<{status: "ok"|"no-verdict", waivers?: object, coverage: object,
|
|
155
|
+
* report: {text: string, json: string}}>}
|
|
156
|
+
* `status: "no-verdict"` carries no `waivers` payload — the verdict was
|
|
157
|
+
* withheld, and the envelope's `coverage` block is the whole answer (#608).
|
|
158
|
+
* @throws {Error} whenever the run's law is malformed — exit-3 class, the same
|
|
159
|
+
* posture `check` takes on a malformed config. Incomplete coverage returns
|
|
160
|
+
* the structured no-verdict envelope instead of throwing (#608).
|
|
157
161
|
*/
|
|
158
162
|
export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
|
|
159
163
|
const { root, provider, marker, analysis, graph } = commandContext;
|
|
@@ -180,25 +184,23 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
|
|
|
180
184
|
// A waiver surface over a tree it could not fully read is a lottery ticket,
|
|
181
185
|
// not a surface: a file the analyzer never judged contributes no raw
|
|
182
186
|
// violation, so every waiver that names it reads as stale and the report
|
|
183
|
-
// says "covers nothing" about a finding the run never looked at.
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
`would read as covering nothing it never saw. Fix the unanalyzed files and re-run.`,
|
|
201
|
-
);
|
|
187
|
+
// says "covers nothing" about a finding the run never looked at. Refused
|
|
188
|
+
// through the one structured contract `./coverage-verdict.mjs` builds
|
|
189
|
+
// (#608) — "could not look" must never read as "looked and found nothing".
|
|
190
|
+
// A whole-file failure whose file a `coverage.unowned` row accepts is
|
|
191
|
+
// withdrawn first, exactly as `check` withdraws it (`./check.mjs`'s
|
|
192
|
+
// `acceptedUnclaimed`): its state is a recorded acceptance this very report
|
|
193
|
+
// is about to name, not a hole the run failed to look at.
|
|
194
|
+
const completeness = coverageVerdict(commandContext, {
|
|
195
|
+
acceptedFiles: unownedCoverage.acceptedFiles,
|
|
196
|
+
});
|
|
197
|
+
if (!completeness.complete) {
|
|
198
|
+
return coverageRefusal({
|
|
199
|
+
command: "waivers",
|
|
200
|
+
commandContext,
|
|
201
|
+
what: "measuring the waiver surface",
|
|
202
|
+
acceptedFiles: unownedCoverage.acceptedFiles,
|
|
203
|
+
});
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
// F07: a waiver surface measured against a graph that cannot see the
|
|
@@ -226,10 +228,13 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
|
|
|
226
228
|
projects: Object.keys(graph.nodes).length,
|
|
227
229
|
analyzedFiles: analysis.analyzed,
|
|
228
230
|
imports: analysis.imports.length,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
// The withdrawn list — whole-file failures a `coverage.unowned` row
|
|
232
|
+
// accepts are already named in `result.unownedAcceptances` below, so
|
|
233
|
+
// repeating them here would double-count the same acceptance. On this
|
|
234
|
+
// path the list is empty by construction: anything unwithdrawn refused
|
|
235
|
+
// above.
|
|
236
|
+
notAnalyzed: completeness.notAnalyzed,
|
|
237
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
233
238
|
// `remainingMs` reflects the wall clock at the moment of THIS run, not the
|
|
234
239
|
// workspace — it is expected to differ between two runs of an unchanged
|
|
235
240
|
// tree, by design (`../governance/clock.mjs`). Disclosed here, in-band,
|
|
@@ -282,6 +287,9 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
|
|
|
282
287
|
return {
|
|
283
288
|
status: "ok",
|
|
284
289
|
waivers: result,
|
|
290
|
+
// The same `coverage` block the envelope carries, so both return shapes —
|
|
291
|
+
// this one and the no-verdict refusal's (#608) — expose it under one key.
|
|
292
|
+
coverage,
|
|
285
293
|
report: {
|
|
286
294
|
text: formatWaiversReport(result),
|
|
287
295
|
json: renderJson(envelope),
|
|
@@ -107,15 +107,68 @@ export function declarationDigest(intent) {
|
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The identity string of one graph edge, in the canonical spelling
|
|
112
|
+
* `source>target:type` — the `(source, target, type)` identity design §1
|
|
113
|
+
* names. The ONE spelling the evolution events' `observed.edges` and
|
|
114
|
+
* `affected.boundaries` use: this module owns it, and `classifyEvolution`
|
|
115
|
+
* maps every edge it is handed through this function, so there is exactly
|
|
116
|
+
* one definition of "same edge" and no second spelling to drift.
|
|
117
|
+
*
|
|
118
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function edgeEvolutionIdentity({ source, target, type }) {
|
|
122
|
+
return `${source}>${target}:${type}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The one accepted input shape for an `observed.edges` entry: the raw
|
|
127
|
+
* `{source, target, type}` triple. A caller handing over a ready-made string
|
|
128
|
+
* would be choosing a second spelling of "same edge", so a string is refused
|
|
129
|
+
* loudly rather than accepted as one shape more — the identity string is this
|
|
130
|
+
* module's output, never its input.
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} entry
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function evolutionBoundary(entry) {
|
|
136
|
+
if (
|
|
137
|
+
typeof entry !== "object" ||
|
|
138
|
+
entry === null ||
|
|
139
|
+
!("source" in entry) ||
|
|
140
|
+
typeof entry.source !== "string" ||
|
|
141
|
+
entry.source === "" ||
|
|
142
|
+
!("target" in entry) ||
|
|
143
|
+
typeof entry.target !== "string" ||
|
|
144
|
+
entry.target === "" ||
|
|
145
|
+
!("type" in entry) ||
|
|
146
|
+
typeof entry.type !== "string"
|
|
147
|
+
) {
|
|
148
|
+
throw new TypeError(
|
|
149
|
+
"classifyEvolution: observed.edges entries must be {source, target, type} triples — " +
|
|
150
|
+
"the identity string is classifyEvolution's own output spelling, never an input",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// The guard above has verified every property; the annotation only states
|
|
154
|
+
// what it proved.
|
|
155
|
+
return edgeEvolutionIdentity(
|
|
156
|
+
/** @type {{source: string, target: string, type: string}} */ (entry),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
110
160
|
/**
|
|
111
161
|
* @typedef {object} EvolutionEvidence
|
|
112
162
|
* @property {{projects?: {added: string[], removed: string[], changed: string[]},
|
|
113
|
-
* edges?: {added: string
|
|
163
|
+
* edges?: {added: {source: string, target: string, type: string}[],
|
|
164
|
+
* removed: {source: string, target: string, type: string}[]},
|
|
114
165
|
* policyChanged?: boolean|null, policyOneSided?: boolean,
|
|
115
166
|
* provenanceChanged?: boolean|null}} [observed]
|
|
116
|
-
* The structural diff between base and head: project names and edge
|
|
117
|
-
*
|
|
118
|
-
*
|
|
167
|
+
* The structural diff between base and head: project names and raw edge
|
|
168
|
+
* triples that were added, removed, or changed. The triples are mapped
|
|
169
|
+
* through `edgeEvolutionIdentity` here — the identity spelling is this
|
|
170
|
+
* module's own, so `affected.boundaries` comes out as identity strings
|
|
171
|
+
* whichever shape the caller held. Empty by default. `policyChanged` — whether the policy fingerprint changed
|
|
119
172
|
* between base and head; `null` is "could not be compared": exactly one side
|
|
120
173
|
* records the policy (`policyOneSided: true`) or neither does
|
|
121
174
|
* (both-absent). `true` is a disclosure, never a refusal. `policyOneSided`
|
|
@@ -197,9 +250,9 @@ export function declarationDigest(intent) {
|
|
|
197
250
|
* absent (`null`) ⇒ NOT asserted, note added |
|
|
198
251
|
*
|
|
199
252
|
* The `affected` identities are derived from the same signals, never from a
|
|
200
|
-
* second opinion: changed project names, changed
|
|
201
|
-
*
|
|
202
|
-
* ids whose lineage moved.
|
|
253
|
+
* second opinion: changed project names, the changed edges under the one
|
|
254
|
+
* identity spelling (`edgeEvolutionIdentity`), the constraint/intent rows
|
|
255
|
+
* whose verdict was not `pass`/`matched`, and the ADR ids whose lineage moved.
|
|
203
256
|
*
|
|
204
257
|
* @param {EvolutionEvidence} [input]
|
|
205
258
|
* @returns {EvolutionClassification}
|
|
@@ -211,8 +264,8 @@ export function classifyEvolution(input = {}) {
|
|
|
211
264
|
const addedProjects = projects.added ?? [];
|
|
212
265
|
const removedProjects = projects.removed ?? [];
|
|
213
266
|
const changedProjects = projects.changed ?? [];
|
|
214
|
-
const addedEdges = edges.added ?? [];
|
|
215
|
-
const removedEdges = edges.removed ?? [];
|
|
267
|
+
const addedEdges = (edges.added ?? []).map(evolutionBoundary);
|
|
268
|
+
const removedEdges = (edges.removed ?? []).map(evolutionBoundary);
|
|
216
269
|
const structureChanged =
|
|
217
270
|
addedProjects.length +
|
|
218
271
|
removedProjects.length +
|