@ecoma-io/archkeep 0.21.0 → 0.22.1
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/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/contract.md +32 -5
- package/src/analysis/csharp.mjs +18 -0
- package/src/analysis/go.mjs +18 -0
- package/src/analysis/java.mjs +15 -0
- package/src/analysis/kotlin.mjs +15 -0
- package/src/analysis/python.mjs +25 -3
- package/src/analysis/rust.mjs +18 -0
- package/src/analysis/source-util.mjs +113 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/canonical.mjs +43 -25
- package/src/commands/README.md +63 -12
- package/src/commands/change-intent.mjs +25 -1
- package/src/commands/change.mjs +90 -40
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +126 -19
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +191 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +70 -29
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +194 -2
- 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 +29 -11
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +41 -26
- package/src/commands/impact.mjs +17 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +93 -16
- package/src/commands/scenario.mjs +28 -18
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +96 -9
- package/src/intent/intent-manifest.json +83 -39
- package/src/lsp/diagnose.mjs +12 -3
- package/src/report/discover-text.mjs +31 -9
- package/src/report/graph-text.mjs +25 -5
- package/src/report/json.mjs +32 -5
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/verify-gate-attestation.mjs +323 -0
- package/src/workspace.mjs +126 -2
package/src/commands/delta.mjs
CHANGED
|
@@ -23,8 +23,12 @@
|
|
|
23
23
|
* exit 1, beside `check` and `fitness`; `./change.mjs` later became the
|
|
24
24
|
* fourth, over a different question — declared intent versus observed delta.
|
|
25
25
|
*
|
|
26
|
-
* Refusals
|
|
27
|
-
*
|
|
26
|
+
* Refusals — a delta that could not honestly classify must never read as
|
|
27
|
+
* "no change". Incomplete CURRENT coverage on the compare side returns the
|
|
28
|
+
* structured no-verdict envelope `./coverage-verdict.mjs` builds (#608) —
|
|
29
|
+
* status "no-verdict", exit 3, a `coverage` block naming every file and site
|
|
30
|
+
* the run could not judge — where a parser and `--output` can read it; the
|
|
31
|
+
* rest are throws, exit 3 upstream:
|
|
28
32
|
* - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
|
|
29
33
|
* (`./delta-snapshot.mjs`'s loader owns those);
|
|
30
34
|
* - a provider mismatch between baseline and this run (`providerMismatch`) —
|
|
@@ -32,8 +36,8 @@
|
|
|
32
36
|
* across two different project models is not trustworthy: the same tree
|
|
33
37
|
* attributed to different projects would classify a rename as an
|
|
34
38
|
* introduced/resolved pair the code does not contain;
|
|
35
|
-
* - incomplete
|
|
36
|
-
*
|
|
39
|
+
* - incomplete coverage on the CAPTURE side (`refuseUnjudgeableHead`), where
|
|
40
|
+
* there is no envelope to withhold — a baseline write has no verdict;
|
|
37
41
|
* - an Nx workspace with polyglot manifests but no plugin registration — the
|
|
38
42
|
* same silently-under-representing graph `graph`/`diff` refuse.
|
|
39
43
|
*
|
|
@@ -62,7 +66,11 @@
|
|
|
62
66
|
*/
|
|
63
67
|
import { createRequire } from "node:module";
|
|
64
68
|
|
|
65
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
blindSpotRows,
|
|
71
|
+
isWholeFileFailure,
|
|
72
|
+
unresolvableLiteralCount,
|
|
73
|
+
} from "../analysis/source-util.mjs";
|
|
66
74
|
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
67
75
|
import { referenceTime } from "../governance/clock.mjs";
|
|
68
76
|
import {
|
|
@@ -97,6 +105,7 @@ import {
|
|
|
97
105
|
} from "./delta-snapshot.mjs";
|
|
98
106
|
import { computeDiff } from "./diff.mjs";
|
|
99
107
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
108
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
100
109
|
import { resolveProvenance } from "./provenance.mjs";
|
|
101
110
|
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
102
111
|
|
|
@@ -105,20 +114,21 @@ const require = createRequire(import.meta.url);
|
|
|
105
114
|
const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json");
|
|
106
115
|
|
|
107
116
|
/**
|
|
108
|
-
* Refuses the
|
|
109
|
-
*
|
|
117
|
+
* Refuses the unregistered-plugin head state no delta side may be built over:
|
|
118
|
+
* on an Nx workspace whose `nx.json` does not register this plugin but whose
|
|
119
|
+
* tracked files include polyglot manifests under project roots.
|
|
110
120
|
*
|
|
111
121
|
* Exported since `change` arrived because that command builds its comparison
|
|
112
|
-
* over the same
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
122
|
+
* over the same head state — a graph that under-represents the tree would
|
|
123
|
+
* reconcile a declaration against architecture nobody observed — and a second
|
|
124
|
+
* copy of the refusal is where the two commands would drift into answering
|
|
125
|
+
* "may this head be judged?" differently.
|
|
116
126
|
*
|
|
117
127
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
118
128
|
* @param {string} activity Which mode is refusing, for the message.
|
|
119
|
-
* @throws {Error} on
|
|
129
|
+
* @throws {Error} on the unregistered-plugin graph.
|
|
120
130
|
*/
|
|
121
|
-
export function
|
|
131
|
+
export function refusePluginGapHead(commandContext, activity) {
|
|
122
132
|
const { provider, pluginGap } = commandContext;
|
|
123
133
|
if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
|
|
124
134
|
throw new Error(
|
|
@@ -130,11 +140,45 @@ export function refuseUnjudgeableHead(commandContext, activity) {
|
|
|
130
140
|
`manifests if they are not in use.`,
|
|
131
141
|
);
|
|
132
142
|
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Refuses, as a throw, the two head states no delta side may be built over:
|
|
147
|
+
* the unregistered-plugin graph (`refusePluginGapHead`) and incomplete
|
|
148
|
+
* analysis coverage.
|
|
149
|
+
*
|
|
150
|
+
* The coverage half stayed a throw through #602's unification of the
|
|
151
|
+
* graph-family refusal, and it stays one here — but only for the CAPTURE
|
|
152
|
+
* side: `captureDelta` writes a baseline, and a write that cannot be honest
|
|
153
|
+
* has no verdict to withhold, so stderr is the only face it has. The compare
|
|
154
|
+
* side (`deltaCommand`, `changeCommand`) refuses coverage through
|
|
155
|
+
* `./coverage-verdict.mjs`'s structured envelope (#608) instead and calls only
|
|
156
|
+
* `refusePluginGapHead`, so a withheld verdict reaches a parser and
|
|
157
|
+
* `--output` in-band.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
160
|
+
* @param {string} activity Which mode is refusing, for the message.
|
|
161
|
+
* @throws {Error} on either condition.
|
|
162
|
+
*/
|
|
163
|
+
export function refuseUnjudgeableHead(commandContext, activity) {
|
|
164
|
+
refusePluginGapHead(commandContext, activity);
|
|
133
165
|
const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
|
|
134
|
-
|
|
166
|
+
|
|
167
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
168
|
+
if (notAnalyzed.length > 0 || blindSpotCount > 0) {
|
|
135
169
|
throw new Error(
|
|
136
|
-
`archkeep: cannot ${activity} —
|
|
137
|
-
|
|
170
|
+
`archkeep: cannot ${activity} — ` +
|
|
171
|
+
[
|
|
172
|
+
notAnalyzed.length > 0
|
|
173
|
+
? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
|
|
174
|
+
: null,
|
|
175
|
+
blindSpotCount > 0
|
|
176
|
+
? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
|
|
177
|
+
: null,
|
|
178
|
+
]
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join(", ") +
|
|
181
|
+
`, so the evidence would ` +
|
|
138
182
|
`miss violations living there and a later classification would misread the gap as a ` +
|
|
139
183
|
`code change. Fix the unanalyzed files and re-run.`,
|
|
140
184
|
);
|
|
@@ -181,14 +225,7 @@ export function captureDelta(commandContext, { config }) {
|
|
|
181
225
|
complete: true,
|
|
182
226
|
analyzedFiles: analysis.analyzed,
|
|
183
227
|
notAnalyzed: [],
|
|
184
|
-
blindSpots: analysis.failures
|
|
185
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
186
|
-
.map(({ sourceFile, line, column, reason }) => ({
|
|
187
|
-
file: sourceFile,
|
|
188
|
-
line,
|
|
189
|
-
column,
|
|
190
|
-
reason,
|
|
191
|
-
})),
|
|
228
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
192
229
|
},
|
|
193
230
|
graph,
|
|
194
231
|
records: analysis.imports,
|
|
@@ -387,15 +424,21 @@ const short = (fingerprint) =>
|
|
|
387
424
|
* `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
|
|
388
425
|
* custom-rule host's two injectable seams, passed through to
|
|
389
426
|
* `customRulesForDelta`.
|
|
390
|
-
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta
|
|
427
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta?: object,
|
|
391
428
|
* coverage: object,
|
|
392
|
-
* eventWrite
|
|
393
|
-
* report: {text: string, json: string, sarif
|
|
429
|
+
* eventWrite?: {id: string, duplicate: boolean}|null,
|
|
430
|
+
* report: {text: string, json: string, sarif?: string}}>} `delta` carries
|
|
394
431
|
* the additive `classifications`/`affected` fields (design §1); `eventWrite`
|
|
395
432
|
* is `null` unless `eventOut` was given, then the store's answer for the
|
|
396
|
-
* event that was (or already was) recorded.
|
|
433
|
+
* event that was (or already was) recorded. `status: "no-verdict"` from the
|
|
434
|
+
* coverage refusal (#608) carries neither `delta` nor `eventWrite` — the
|
|
435
|
+
* comparison was withheld before any event work, and the envelope's
|
|
436
|
+
* `coverage` block plus its `decision.reason` are the whole answer; the
|
|
437
|
+
* report then has no `sarif` face (there are no findings to render).
|
|
397
438
|
* @throws {Error} on every refusal the module header lists, and on a
|
|
398
439
|
* custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
|
|
440
|
+
* Incomplete head coverage returns the structured no-verdict envelope
|
|
441
|
+
* instead of throwing (#608); the unregistered-plugin graph keeps its throw.
|
|
399
442
|
*/
|
|
400
443
|
export async function deltaCommand(
|
|
401
444
|
baselinePath,
|
|
@@ -411,7 +454,22 @@ export async function deltaCommand(
|
|
|
411
454
|
) {
|
|
412
455
|
const { root, provider, marker, graph, analysis, tracked } = commandContext;
|
|
413
456
|
|
|
414
|
-
|
|
457
|
+
// The plugin-gap refusal stays a throw; the coverage refusal returns the one
|
|
458
|
+
// structured envelope `./coverage-verdict.mjs` builds (#608): a verdict
|
|
459
|
+
// withheld because the run could not read the tree belongs in-band, where a
|
|
460
|
+
// parser and `--output` can read it — the same contract the graph family has
|
|
461
|
+
// run since #602. The capture side keeps the throw (`refuseUnjudgeableHead`
|
|
462
|
+
// above), because a baseline write has no envelope to withhold.
|
|
463
|
+
refusePluginGapHead(commandContext, "compute a delta");
|
|
464
|
+
const completeness = coverageVerdict(commandContext);
|
|
465
|
+
if (!completeness.complete) {
|
|
466
|
+
return coverageRefusal({
|
|
467
|
+
command: "delta",
|
|
468
|
+
commandContext,
|
|
469
|
+
what: "computing a delta",
|
|
470
|
+
decision: true,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
415
473
|
if (!config) {
|
|
416
474
|
throw new Error(
|
|
417
475
|
"archkeep: cannot compute a delta without a boundary config — both sides are re-judged " +
|
|
@@ -630,7 +688,13 @@ export async function deltaCommand(
|
|
|
630
688
|
};
|
|
631
689
|
const evolution = classifyDeltaEvolution(deltaPayload, {
|
|
632
690
|
projects: structural.projects,
|
|
633
|
-
|
|
691
|
+
// The raw triples, not `structural.edges` (the mapped identity strings):
|
|
692
|
+
// `classifyEvolution` owns the spelling and takes the triples. The
|
|
693
|
+
// envelope's `structural` and the event's `observed` keep the mapped
|
|
694
|
+
// strings this run derived above — one mapping per command, and the
|
|
695
|
+
// classification's `affected.boundaries` comes out under the same
|
|
696
|
+
// spelling because it is mapped inside `classifyEvolution` itself.
|
|
697
|
+
edges: { added: structuralDiff.addedEdges, removed: structuralDiff.removedEdges },
|
|
634
698
|
codeDrift,
|
|
635
699
|
});
|
|
636
700
|
|
|
@@ -683,9 +747,7 @@ export async function deltaCommand(
|
|
|
683
747
|
analyzedFiles: analysis.analyzed,
|
|
684
748
|
imports: analysis.imports.length,
|
|
685
749
|
notAnalyzed: [],
|
|
686
|
-
blindSpots: analysis.failures
|
|
687
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
688
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
750
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
689
751
|
notes,
|
|
690
752
|
};
|
|
691
753
|
|
package/src/commands/diff.mjs
CHANGED
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { readFileSync } from "node:fs";
|
|
42
42
|
|
|
43
|
-
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
44
43
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
45
44
|
import { computeRuleImpact } from "./edge-constraints.mjs";
|
|
45
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
46
46
|
import { SCHEMA_VERSION } from "../report/json.mjs";
|
|
47
47
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
48
48
|
import { formatDiffReport } from "../report/diff-text.mjs";
|
|
@@ -349,11 +349,14 @@ export function computeDiff(baseline, head) {
|
|
|
349
349
|
* When omitted, reads from the real filesystem. `config` is the loaded
|
|
350
350
|
* boundary config; when provided, rule-impact analysis is computed alongside
|
|
351
351
|
* the structural diff.
|
|
352
|
-
* @returns {{status: "ok"|"no-verdict", diff
|
|
353
|
-
* report: {text: string, json: string}}}
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
352
|
+
* @returns {{status: "ok"|"no-verdict", diff?: object, coverage: object,
|
|
353
|
+
* report: {text: string, json: string}}} `diff` is absent under
|
|
354
|
+
* `status: "no-verdict"` — the coverage refusal (#608) withholds the
|
|
355
|
+
* comparison, and the envelope's `coverage` block is the whole answer.
|
|
356
|
+
* @throws {Error} when the baseline cannot be read or is incomplete, or when
|
|
357
|
+
* an Nx workspace has polyglot manifests but the plugin is not registered
|
|
358
|
+
* (the inline throw in the body). An incomplete head returns the
|
|
359
|
+
* structured no-verdict envelope instead of throwing (#608).
|
|
357
360
|
*/
|
|
358
361
|
export function diffCommand(
|
|
359
362
|
baselinePath,
|
|
@@ -379,19 +382,18 @@ export function diffCommand(
|
|
|
379
382
|
// is a caller error, not a workspace fact, and it should name the file.
|
|
380
383
|
const baseline = readBaseline(baselinePath);
|
|
381
384
|
|
|
382
|
-
// Refuse an incomplete head
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
);
|
|
385
|
+
// Refuse an incomplete head through the one structured contract
|
|
386
|
+
// `./coverage-verdict.mjs` builds (#608): the diff is withheld in-band —
|
|
387
|
+
// status "no-verdict", exit 3, a `coverage` block naming every file and
|
|
388
|
+
// site the run could not judge — where a parser and `--output` can read it.
|
|
389
|
+
// The reasoning is the incomplete-baseline refusal's: every "added" or
|
|
390
|
+
// "removed" entry would be ambiguous. That baseline refusal stays a throw —
|
|
391
|
+
// `parseBaseline` above throws it through the default reader — because a
|
|
392
|
+
// baseline nobody captured correctly is a caller error about a file, not a
|
|
393
|
+
// coverage fact about THIS tree.
|
|
394
|
+
const completeness = coverageVerdict(commandContext);
|
|
395
|
+
if (!completeness.complete) {
|
|
396
|
+
return coverageRefusal({ command: "diff", commandContext, what: "diffing the head graph" });
|
|
395
397
|
}
|
|
396
398
|
|
|
397
399
|
const head = buildHeadSnapshot(commandContext);
|
|
@@ -404,9 +406,7 @@ export function diffCommand(
|
|
|
404
406
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
405
407
|
imports: commandContext.analysis.imports.length,
|
|
406
408
|
notAnalyzed: [],
|
|
407
|
-
blindSpots:
|
|
408
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
409
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
409
|
+
blindSpots: completeness.blindSpots,
|
|
410
410
|
notes: [],
|
|
411
411
|
};
|
|
412
412
|
|
|
@@ -417,6 +417,12 @@ export function diffCommand(
|
|
|
417
417
|
projects: baseline.projects.length,
|
|
418
418
|
edges: baseline.dependencies.length,
|
|
419
419
|
toolVersion: baseline.toolVersion,
|
|
420
|
+
// The provenance the baseline itself recorded — which commit, remote,
|
|
421
|
+
// and dirtiness the captured side names (#609). The head side's own
|
|
422
|
+
// provenance rides the envelope's `workspace.provenance`; without the
|
|
423
|
+
// baseline's, a consumer could not tell WHICH revision the empty or
|
|
424
|
+
// non-empty diff is measured against.
|
|
425
|
+
provenance: baseline.provenance ?? null,
|
|
420
426
|
},
|
|
421
427
|
head: { projects: head.projects.length, edges: head.dependencies.length },
|
|
422
428
|
addedProjects: diff.addedProjects,
|
|
@@ -525,8 +531,9 @@ export function diffCommand(
|
|
|
525
531
|
// no depConstraints violations were introduced or resolved on the changed
|
|
526
532
|
// edges. Run `check` for the complete verdict.
|
|
527
533
|
coverage.notes.push(
|
|
528
|
-
"per-edge rule-impact covers only depConstraints (3 of 15 violation types
|
|
529
|
-
"
|
|
534
|
+
"per-edge rule-impact covers only depConstraints (3 of 15 violation types; standing " +
|
|
535
|
+
"edges adjacent to a tags-changed project are re-judged under both sides' tags). A " +
|
|
536
|
+
"dependency with no rule-impact may still violate npm-ban, circular-dependency, " +
|
|
530
537
|
"lazy-load, or other rules that require import-site details. Run check for the " +
|
|
531
538
|
"complete verdict.",
|
|
532
539
|
);
|
|
@@ -20,18 +20,23 @@
|
|
|
20
20
|
*
|
|
21
21
|
* ## The empty-result invariant
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* under-represent the real architecture, and a candidate derived from it
|
|
29
|
-
* would be a fabrication wearing a proposal's name.
|
|
23
|
+
* Completeness comes from `./coverage-verdict.mjs`'s shared constructor —
|
|
24
|
+
* the same three-axis law (no whole-file failure, no unjudged blind spot,
|
|
25
|
+
* at least one file analyzed) that `graph` and `check` judge completeness
|
|
26
|
+
* over. The graph-family restatement this replaces is how the zero-analysis
|
|
27
|
+
* axis went missing here (#619).
|
|
30
28
|
*
|
|
31
29
|
* A workspace with zero projects is NOT a refusal: it is the empty proposal
|
|
32
30
|
* with `unknown: true` (`evaluateDiscovery`'s contract), because zero observed
|
|
33
31
|
* projects is a complete observation — the honest answer is "nothing to
|
|
34
|
-
* propose", not a fabricated candidate set.
|
|
32
|
+
* propose", not a fabricated candidate set. The constructor's zero-analysis
|
|
33
|
+
* clause is overridden for this case: if there is nothing to observe, the
|
|
34
|
+
* observation is complete.
|
|
35
|
+
*
|
|
36
|
+
* An Nx workspace with polyglot manifests and no plugin registration is
|
|
37
|
+
* refused the same way `graph` refuses it — the graph would silently
|
|
38
|
+
* under-represent the real architecture, and a candidate derived from it
|
|
39
|
+
* would be a fabrication wearing a proposal's name.
|
|
35
40
|
*
|
|
36
41
|
* ## Determinism
|
|
37
42
|
*
|
|
@@ -40,10 +45,11 @@
|
|
|
40
45
|
* byte-identical text and JSON — the same promise `graph`'s snapshots make,
|
|
41
46
|
* which is what lets a consumer `diff` two proposals meaningfully.
|
|
42
47
|
*/
|
|
43
|
-
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
44
48
|
import { evaluateDiscovery } from "../governance/discovery-proposal.mjs";
|
|
45
49
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
46
50
|
import { formatDiscoverReport } from "../report/discover-text.mjs";
|
|
51
|
+
import { coverageIncompleteReasons } from "../verdict.mjs";
|
|
52
|
+
import { coverageVerdict } from "./coverage-verdict.mjs";
|
|
47
53
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
48
54
|
import { resolveProvenance } from "./provenance.mjs";
|
|
49
55
|
import { refuseIncompleteGraph } from "./drift.mjs";
|
|
@@ -127,40 +133,74 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
127
133
|
|
|
128
134
|
refuseIncompleteGraph(commandContext);
|
|
129
135
|
|
|
130
|
-
const notAnalyzed = analysis.failures
|
|
131
|
-
.filter(isWholeFileFailure)
|
|
132
|
-
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
133
|
-
|
|
134
136
|
const observed = buildObserved(commandContext);
|
|
135
137
|
|
|
136
138
|
const proposal = propose ? evaluateDiscovery(observed) : null;
|
|
137
139
|
|
|
140
|
+
// The completeness verdict is the shared constructor's, not this file's:
|
|
141
|
+
// restating the axes here is how the `analyzed > 0` term went missing from
|
|
142
|
+
// this face (#619 — a run that judged no file at all used to report `ok` /
|
|
143
|
+
// `complete: true` / exit 0, byte-for-byte the envelope a clean workspace
|
|
144
|
+
// gets). `coverageVerdict` owns the one law — no whole-file failure, no
|
|
145
|
+
// unjudged site, at least one file analyzed — and the same return shape
|
|
146
|
+
// the envelope and the text face both read.
|
|
147
|
+
//
|
|
148
|
+
// One override: a workspace with zero projects is a complete observation
|
|
149
|
+
// (there is nothing to observe), so the zero-analysis clause does not
|
|
150
|
+
// withhold from it. `evaluateDiscovery`'s contract returns `unknown: true`
|
|
151
|
+
// over an empty project list, and that answer is honest — it does not
|
|
152
|
+
// claim completeness over nothing.
|
|
153
|
+
const verdict = coverageVerdict(commandContext);
|
|
154
|
+
const hasProjects = observed.projects.length > 0;
|
|
155
|
+
const { complete, status, exitCode } = verdict;
|
|
156
|
+
// When there are no projects, override the zero-analysis withdrawal: an
|
|
157
|
+
// empty observation is a complete observation, and the `unknown` proposal
|
|
158
|
+
// is the honest answer.
|
|
159
|
+
const effectiveComplete = hasProjects ? complete : true;
|
|
160
|
+
const effectiveStatus = hasProjects ? status : "ok";
|
|
161
|
+
const effectiveExitCode = hasProjects ? exitCode : 0;
|
|
162
|
+
|
|
138
163
|
// A proposal over an unread tree would be a fabrication wearing a
|
|
139
164
|
// proposal's name: every candidate edge would be ambiguous between "gone"
|
|
140
165
|
// and "never seen". Refuse loudly — the same reasoning `drift`'s refusal
|
|
141
166
|
// gives — rather than print a proposal and a warning that it may be lying.
|
|
142
|
-
|
|
167
|
+
// An unresolvable site is the same fabrication at site granularity (#595):
|
|
168
|
+
// the edge out of it may be missing, and a candidate built over a gap is
|
|
169
|
+
// still a guess. Use the shared verdict's counts rather than re-deriving.
|
|
170
|
+
if (propose && (verdict.notAnalyzed.length > 0 || verdict.blindSpotCount > 0)) {
|
|
143
171
|
throw new Error(
|
|
144
|
-
`archkeep: discover --propose has incomplete coverage —
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
172
|
+
`archkeep: discover --propose has incomplete coverage — ` +
|
|
173
|
+
[
|
|
174
|
+
verdict.notAnalyzed.length > 0
|
|
175
|
+
? `${verdict.notAnalyzed.length} file${verdict.notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
|
|
176
|
+
: null,
|
|
177
|
+
verdict.blindSpotCount > 0
|
|
178
|
+
? `${verdict.blindSpotCount} import site${verdict.blindSpotCount === 1 ? "" : "s"} could not be resolved`
|
|
179
|
+
: null,
|
|
180
|
+
]
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.join(", ") +
|
|
183
|
+
`, so every candidate would be ambiguous between "gone" and "never seen". ` +
|
|
184
|
+
`Fix the unresolved files and sites and re-run.`,
|
|
148
185
|
);
|
|
149
186
|
}
|
|
150
187
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
188
|
+
// The clauses the text face renders over an incomplete run, worded by the
|
|
189
|
+
// same function `verdictFor` joins into `decision.reason` — one wording,
|
|
190
|
+
// two renderings, and neither can drift from the other.
|
|
191
|
+
const coverageIncomplete = coverageIncompleteReasons({
|
|
192
|
+
unchecked: verdict.notAnalyzed.length,
|
|
193
|
+
blindSpots: verdict.blindSpotCount,
|
|
194
|
+
analyzed: analysis.analyzed,
|
|
195
|
+
});
|
|
154
196
|
|
|
155
197
|
const coverage = {
|
|
156
|
-
complete,
|
|
198
|
+
complete: effectiveComplete,
|
|
157
199
|
projects: observed.projects.length,
|
|
158
200
|
analyzedFiles: analysis.analyzed,
|
|
159
201
|
imports: analysis.imports.length,
|
|
160
|
-
notAnalyzed,
|
|
161
|
-
blindSpots:
|
|
162
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
163
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
202
|
+
notAnalyzed: verdict.notAnalyzed,
|
|
203
|
+
blindSpots: verdict.blindSpots,
|
|
164
204
|
notes: [],
|
|
165
205
|
};
|
|
166
206
|
|
|
@@ -176,14 +216,14 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
176
216
|
const envelope = jsonEnvelope({
|
|
177
217
|
command: "discover",
|
|
178
218
|
context,
|
|
179
|
-
status,
|
|
180
|
-
exitCode,
|
|
219
|
+
status: effectiveStatus,
|
|
220
|
+
exitCode: effectiveExitCode,
|
|
181
221
|
coverage,
|
|
182
222
|
result: { discovery, ...(proposal ? { proposal } : {}) },
|
|
183
223
|
});
|
|
184
224
|
|
|
185
225
|
return {
|
|
186
|
-
status,
|
|
226
|
+
status: effectiveStatus,
|
|
187
227
|
discovery,
|
|
188
228
|
proposal,
|
|
189
229
|
coverage,
|
|
@@ -192,6 +232,7 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
192
232
|
discovery,
|
|
193
233
|
proposal,
|
|
194
234
|
coverage,
|
|
235
|
+
coverageIncomplete: hasProjects ? coverageIncomplete : undefined,
|
|
195
236
|
}),
|
|
196
237
|
json: renderJson(envelope),
|
|
197
238
|
},
|
package/src/commands/drift.mjs
CHANGED
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
*
|
|
32
32
|
* - the intent file cannot be read or parsed (strict JSON, validated) → throw
|
|
33
33
|
* → exit 3;
|
|
34
|
-
* - the observed side is incomplete
|
|
35
|
-
* same reasoning as `diff` — every
|
|
36
|
-
* between "gone" and "never seen"
|
|
34
|
+
* - the observed side is incomplete → the structured no-verdict refusal
|
|
35
|
+
* (`./coverage-verdict.mjs`), exit 3, the same reasoning as `diff` — every
|
|
36
|
+
* "project missing" would be ambiguous between "gone" and "never seen" —
|
|
37
|
+
* but in the envelope, not on stderr: the same status/coverage contract
|
|
38
|
+
* `graph`/`context` return over the same condition (#608);
|
|
37
39
|
* - an Nx workspace has polyglot manifests but the plugin is not registered →
|
|
38
40
|
* exit 3, the same refusal `graph`/`diff` make;
|
|
39
41
|
* - a boundary or row side matched no observed project → exit 3, the same
|
|
@@ -74,8 +76,9 @@
|
|
|
74
76
|
* everywhere, never `localeCompare` — so two runs over an unchanged tree and
|
|
75
77
|
* intent produce byte-identical text and JSON.
|
|
76
78
|
*/
|
|
77
|
-
import {
|
|
79
|
+
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
78
80
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
81
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
79
82
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
80
83
|
import { resolveProvenance } from "./provenance.mjs";
|
|
81
84
|
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
@@ -290,11 +293,13 @@ function intentRows(intent) {
|
|
|
290
293
|
* row's `decisionRef`. `configError` carries a boundary-policy load failure
|
|
291
294
|
* the caller chose not to throw at the load site — rethrown here, unchanged,
|
|
292
295
|
* only if an intent row actually cites something.
|
|
293
|
-
* @returns {Promise<{status: "ok", drift
|
|
296
|
+
* @returns {Promise<{status: "ok"|"no-verdict", drift?: object, coverage: object,
|
|
294
297
|
* report: {text: string, json: string}}>}
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
+
* `status: "no-verdict"` carries no `drift` payload — the verdict was
|
|
299
|
+
* withheld, and the envelope's `coverage` block is the whole answer (#608).
|
|
300
|
+
* @throws {Error} on every condition the header lists except the coverage one,
|
|
301
|
+
* which returns instead of throwing, plus a malformed ADR registry — the
|
|
302
|
+
* same loud refusal `provenance` makes for the identical read.
|
|
298
303
|
*/
|
|
299
304
|
export async function driftCommand(commandContext, io = {}) {
|
|
300
305
|
const { root, provider, marker, analysis } = commandContext;
|
|
@@ -302,16 +307,13 @@ export async function driftCommand(commandContext, io = {}) {
|
|
|
302
307
|
refuseIncompleteGraph(commandContext);
|
|
303
308
|
|
|
304
309
|
// A drift verdict cannot be established over a tree it could not fully read.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
`${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so every "project missing" ` +
|
|
313
|
-
`would be ambiguous between "gone" and "never seen". Fix the unanalyzed files and re-run.`,
|
|
314
|
-
);
|
|
310
|
+
// The refusal is the structured one the graph family speaks (#608): the
|
|
311
|
+
// verdict is withheld in-band — status "no-verdict", exit 3, a `coverage`
|
|
312
|
+
// block naming every file and site the run could not judge — where a parser
|
|
313
|
+
// and `--output` can read it, not on stderr where only a human can.
|
|
314
|
+
const completeness = coverageVerdict(commandContext);
|
|
315
|
+
if (!completeness.complete) {
|
|
316
|
+
return coverageRefusal({ command: "drift", commandContext, what: "judging drift" });
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
const intent = await (io.loadIntentOverride ?? loadIntent)(root, {
|
|
@@ -398,9 +400,7 @@ export async function driftCommand(commandContext, io = {}) {
|
|
|
398
400
|
notAnalyzed: [],
|
|
399
401
|
// Drift reads only the graph — provider failures are the same blind spots
|
|
400
402
|
// every other command reports, and a blind spot never prevents a verdict.
|
|
401
|
-
blindSpots: analysis.failures
|
|
402
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
403
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
403
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
404
404
|
// Coverage notes (e.g. an `optional: true` allowed row the team has not
|
|
405
405
|
// built yet) ride here so "optional and absent" never reads as "never
|
|
406
406
|
// checked".
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* enough that merging them would blur the layer boundary the AGENTS.md guards.
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
|
|
45
46
|
import { renderMessage } from "../rules/messages.mjs";
|
|
46
47
|
import { buildReachability } from "../rules/reachability.mjs";
|
|
47
48
|
import {
|
|
@@ -212,7 +213,9 @@ export function declaredEdgeViolationsForCheck(graph, depConstraints) {
|
|
|
212
213
|
* constraint table. The constraint table is the current one — it is what `check`
|
|
213
214
|
* would judge from today, not what some past version judged from.
|
|
214
215
|
*
|
|
215
|
-
* @param {{addedEdges: object[], removedEdges: object[]
|
|
216
|
+
* @param {{addedEdges: object[], removedEdges: object[],
|
|
217
|
+
* changedProjects?: {name: string, changes: {field: string, baseline?: unknown,
|
|
218
|
+
* head?: unknown}[]}[]}} diff From `computeDiff`.
|
|
216
219
|
* @param {object} headNodes The head graph's `nodes` map (for tag lookups).
|
|
217
220
|
* @param {object} headDependencies The head graph's `dependencies` map (for reachability).
|
|
218
221
|
* @param {object[]} baselineProjects The baseline snapshot's project list (each
|
|
@@ -285,6 +288,49 @@ export function computeRuleImpact(
|
|
|
285
288
|
}
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
// Standing edges whose legality a tags-only change can flip (#600): the
|
|
292
|
+
// edge moved in neither direction, so the loops above never see it, but
|
|
293
|
+
// the tags its judgment reads did. An edge adjacent to a project whose
|
|
294
|
+
// tags changed is judged under BOTH sides' tags — violating under head
|
|
295
|
+
// where it was legal under baseline is an introduced violation, the
|
|
296
|
+
// inverse is a resolved one, and a violation under both is pre-existing
|
|
297
|
+
// (unchanged legality is `check`'s finding, not this diff's). Edges the
|
|
298
|
+
// loops above already judged are skipped by identity, so no edge is ever
|
|
299
|
+
// reported twice.
|
|
300
|
+
const tagChangedNames = new Set(
|
|
301
|
+
(diff.changedProjects ?? [])
|
|
302
|
+
.filter((project) => (project.changes ?? []).some((change) => change.field === "tags"))
|
|
303
|
+
.map((project) => project.name),
|
|
304
|
+
);
|
|
305
|
+
if (tagChangedNames.size > 0) {
|
|
306
|
+
const judged = new Set(
|
|
307
|
+
[...diff.addedEdges, ...diff.removedEdges].map((edge) => edgeEvolutionIdentity(edge)),
|
|
308
|
+
);
|
|
309
|
+
for (const edge of baselineDependencies) {
|
|
310
|
+
if (!(tagChangedNames.has(edge.source) || tagChangedNames.has(edge.target))) continue;
|
|
311
|
+
if (judged.has(edgeEvolutionIdentity(edge))) continue;
|
|
312
|
+
const headViolations = judgeEdge(
|
|
313
|
+
edge,
|
|
314
|
+
headNodes,
|
|
315
|
+
headDependencies,
|
|
316
|
+
depConstraints,
|
|
317
|
+
headReachability,
|
|
318
|
+
);
|
|
319
|
+
const baselineViolations = judgeEdge(
|
|
320
|
+
edge,
|
|
321
|
+
baselineNodes,
|
|
322
|
+
baselineDepsMap,
|
|
323
|
+
depConstraints,
|
|
324
|
+
baselineReachability,
|
|
325
|
+
);
|
|
326
|
+
if (headViolations.length > 0 && baselineViolations.length === 0) {
|
|
327
|
+
introduced.push(...headViolations);
|
|
328
|
+
} else if (baselineViolations.length > 0 && headViolations.length === 0) {
|
|
329
|
+
resolved.push(...baselineViolations);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
288
334
|
return { introduced, resolved };
|
|
289
335
|
}
|
|
290
336
|
|