@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
|
@@ -32,7 +32,11 @@
|
|
|
32
32
|
* than explaining constraints from a graph whose edges silently under-represent
|
|
33
33
|
* the real architecture.
|
|
34
34
|
*/
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
blindSpotRows,
|
|
37
|
+
isWholeFileFailure,
|
|
38
|
+
unresolvableLiteralCount,
|
|
39
|
+
} from "../analysis/source-util.mjs";
|
|
36
40
|
import { UsageError } from "../errors.mjs";
|
|
37
41
|
import { judgeEdge } from "./edge-constraints.mjs";
|
|
38
42
|
import { findConstraintsFor } from "../rules/tags.mjs";
|
|
@@ -150,7 +154,13 @@ export function contextCommand(projectName, commandContext, config) {
|
|
|
150
154
|
.filter(isWholeFileFailure)
|
|
151
155
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
152
156
|
|
|
153
|
-
|
|
157
|
+
// The same completeness `check` claims (#595, #599): unjudged sites and
|
|
158
|
+
// a zero-analyzed run defeat it here exactly as they do there, so a
|
|
159
|
+
// context report cannot look complete over a tree the run could not
|
|
160
|
+
// fully read.
|
|
161
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
162
|
+
const complete =
|
|
163
|
+
notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
|
|
154
164
|
const status = complete ? "ok" : "no-verdict";
|
|
155
165
|
const exitCode = complete ? 0 : 3;
|
|
156
166
|
|
|
@@ -160,9 +170,7 @@ export function contextCommand(projectName, commandContext, config) {
|
|
|
160
170
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
161
171
|
imports: commandContext.analysis.imports.length,
|
|
162
172
|
notAnalyzed,
|
|
163
|
-
blindSpots: commandContext.analysis.failures
|
|
164
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
165
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
173
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
166
174
|
notes: [
|
|
167
175
|
"per-edge violations cover only depConstraints (3 of 15 violation types). " +
|
|
168
176
|
"A dependency with no violations here may still violate npm-ban, circular-dependency, " +
|
package/src/commands/context.mjs
CHANGED
|
@@ -214,11 +214,14 @@ export const WORKSPACE_MARKERS = [
|
|
|
214
214
|
* `createWorkspace` returns.
|
|
215
215
|
* @property {string[]} tracked Every tracked file, from `listFiles(root)`.
|
|
216
216
|
* @property {{imports: object[], failures: object[], analyzed: number,
|
|
217
|
-
* analyzedFiles: string[], exemptedFiles: string[]
|
|
217
|
+
* analyzedFiles: string[], exemptedFiles: string[],
|
|
218
|
+
* unsupportedLanguageFiles: string[]}} analysis The
|
|
218
219
|
* whole-tree-then-scoped (native) or scoped-then-analyzed (nx/moon) result —
|
|
219
220
|
* see the branches below for why the order is not the same on all three.
|
|
220
221
|
* `exemptedFiles` is always `[]` on nx/moon: `coverage.exempt` is a
|
|
221
|
-
* native-only `archkeep.json` key.
|
|
222
|
+
* native-only `archkeep.json` key. `unsupportedLanguageFiles` is scoped to
|
|
223
|
+
* the selected files on every branch (#601): a gap row names only files the
|
|
224
|
+
* selected scope could have judged.
|
|
222
225
|
* @property {{boundaryConfig: string|object, tsConfig: object|undefined,
|
|
223
226
|
* boundaryConfigDeclared: boolean, profiles?: string, inline?: boolean}} options
|
|
224
227
|
* What this workspace names its boundary law, its shared tsconfig, and —
|
|
@@ -549,6 +552,7 @@ export function resolveCommandContext(
|
|
|
549
552
|
let failures;
|
|
550
553
|
let analyzed;
|
|
551
554
|
let analyzedFiles;
|
|
555
|
+
let unsupportedLanguageFiles;
|
|
552
556
|
let pluginGap;
|
|
553
557
|
let unownedGap;
|
|
554
558
|
let unclaimedGap;
|
|
@@ -664,6 +668,12 @@ export function resolveCommandContext(
|
|
|
664
668
|
...jvmIndexFailures(workspace),
|
|
665
669
|
]);
|
|
666
670
|
analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
|
|
671
|
+
// Scoped exactly like `analyzedFiles` above (#601): a gap row must name
|
|
672
|
+
// only files the selected scope could have judged, and leaving this
|
|
673
|
+
// undefined here would crash `check`'s gap assembly instead of reporting.
|
|
674
|
+
unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
|
|
675
|
+
selectedFiles.has(file),
|
|
676
|
+
);
|
|
667
677
|
analyzed = analyzedFiles.length;
|
|
668
678
|
// Unaffected by `paths`: an exempted file is by definition unowned by any
|
|
669
679
|
// project, so it was never a candidate for `owned`/`selected` in the
|
|
@@ -787,6 +797,13 @@ export function resolveCommandContext(
|
|
|
787
797
|
]);
|
|
788
798
|
unclaimedGap = { files: unclaimedFiles };
|
|
789
799
|
analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
|
|
800
|
+
// Scoped exactly like the native branch's own filter above (#601): a gap
|
|
801
|
+
// row must name only files the selected scope could have judged, and
|
|
802
|
+
// leaving this undefined here would crash `check`'s gap assembly instead
|
|
803
|
+
// of reporting.
|
|
804
|
+
unsupportedLanguageFiles = wholeTreeAnalysis.unsupportedLanguageFiles.filter((file) =>
|
|
805
|
+
selectedFiles.has(file),
|
|
806
|
+
);
|
|
790
807
|
analyzed = analyzedFiles.length;
|
|
791
808
|
// `coverage.exempt` is a native-only key (`../providers/native/coverage.mjs`'s
|
|
792
809
|
// header: "Nx has no equivalent question") — Moon carries no such list.
|
|
@@ -835,7 +852,10 @@ export function resolveCommandContext(
|
|
|
835
852
|
paths,
|
|
836
853
|
{ root, cwd, tracked },
|
|
837
854
|
);
|
|
838
|
-
({ imports, failures, analyzed, analyzedFiles } = analyzeWorkspace(
|
|
855
|
+
({ imports, failures, analyzed, analyzedFiles, unsupportedLanguageFiles } = analyzeWorkspace(
|
|
856
|
+
workspace,
|
|
857
|
+
selected,
|
|
858
|
+
));
|
|
839
859
|
// Unclaimed analyzable files — `unclaimedFileFailures` above — join
|
|
840
860
|
// unconditionally, the same workspace-wide posture native's own
|
|
841
861
|
// `discovered.failures` has, so a scoped `check <path>` cannot hide an
|
|
@@ -876,7 +896,14 @@ export function resolveCommandContext(
|
|
|
876
896
|
graph,
|
|
877
897
|
workspace,
|
|
878
898
|
tracked,
|
|
879
|
-
analysis: {
|
|
899
|
+
analysis: {
|
|
900
|
+
imports,
|
|
901
|
+
failures,
|
|
902
|
+
analyzed,
|
|
903
|
+
analyzedFiles,
|
|
904
|
+
exemptedFiles,
|
|
905
|
+
unsupportedLanguageFiles,
|
|
906
|
+
},
|
|
880
907
|
options,
|
|
881
908
|
pluginGap,
|
|
882
909
|
unownedGap,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE incomplete-coverage refusal contract, and the one completeness
|
|
3
|
+
* every command's verdict is a claim about (#608).
|
|
4
|
+
*
|
|
5
|
+
* #602 gave the graph family (`check`, `graph`, `discover`, `explain`,
|
|
6
|
+
* `context`) a structured refusal: `status: "no-verdict"`, exit 3, a
|
|
7
|
+
* `coverage` block naming what the run could not read — in the envelope,
|
|
8
|
+
* where a parser and `--output` can read it. Every command here that refuses
|
|
9
|
+
* the same condition refused it by throwing: a stderr sentence, an empty
|
|
10
|
+
* stdout, no envelope, nothing under `--output`. Same refusal class, two
|
|
11
|
+
* machine contracts — while `docs/reference/exit-codes.md` already documents
|
|
12
|
+
* the structured one for every descriptive command. The functions here are
|
|
13
|
+
* the shared construction the throw family now goes through, so the two
|
|
14
|
+
* families cannot drift again:
|
|
15
|
+
*
|
|
16
|
+
* - `coverageVerdict` computes the one completeness the envelope law
|
|
17
|
+
* (`../report/json.mjs`) already enforces — no whole-file failure, no
|
|
18
|
+
* unjudged blind spot, at least one file analyzed — and the status/exit
|
|
19
|
+
* pair that completeness implies. The failure classes are read through
|
|
20
|
+
* `../analysis/source-util.mjs`'s classifiers, never an inline filter: the
|
|
21
|
+
* class line is theirs alone to own.
|
|
22
|
+
* - `coverageRefusal` builds the refusal result itself: the envelope through
|
|
23
|
+
* `jsonEnvelope` (so the envelope invariants hold by construction, and a
|
|
24
|
+
* refusal cannot accidentally claim `complete: true`), and a text face
|
|
25
|
+
* whose coverage lines come from `coverageIncompleteReasons` — the same
|
|
26
|
+
* clauses `check`'s text report renders — so a terminal reader is told the
|
|
27
|
+
* same thing the envelope tells a parser.
|
|
28
|
+
*
|
|
29
|
+
* What still throws: the plugin-gap refusal (a graph that under-represents
|
|
30
|
+
* the workspace is a different refusal, kept loud in every command that has
|
|
31
|
+
* one — `drift.mjs`'s `refuseIncompleteGraph` and `delta.mjs`'s
|
|
32
|
+
* `refusePluginGapHead` are the two shared guards), input refusals (a
|
|
33
|
+
* malformed baseline file, a missing intent), and the capture modes
|
|
34
|
+
* (`delta --capture`, `history --capture`) whose product is a snapshot file
|
|
35
|
+
* with no envelope contract to refuse through.
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
blindSpotRows,
|
|
42
|
+
isWholeFileFailure,
|
|
43
|
+
unresolvableLiteralCount,
|
|
44
|
+
} from "../analysis/source-util.mjs";
|
|
45
|
+
import { EXIT, coverageIncompleteReasons } from "../verdict.mjs";
|
|
46
|
+
import { buildDecision } from "../report/evidence.mjs";
|
|
47
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
48
|
+
import { formatCoverageIncomplete } from "../report/text.mjs";
|
|
49
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} CoverageVerdict
|
|
53
|
+
* @property {{file: string, reason: string}[]} notAnalyzed One row per
|
|
54
|
+
* whole-file failure — the files the run never read.
|
|
55
|
+
* @property {object[]} blindSpots Every positioned failure, all permanent
|
|
56
|
+
* classes — the run's disclosure of every site it saw and did not judge.
|
|
57
|
+
* @property {number} blindSpotCount The count of sites that WITHHOLD the
|
|
58
|
+
* verdict (unresolvable literals referencing the workspace; dynamic and
|
|
59
|
+
* external sites disclosed but exit-neutral).
|
|
60
|
+
* @property {boolean} complete Whether the run judged everything in scope.
|
|
61
|
+
* @property {"ok"|"no-verdict"} status The verdict the completeness implies.
|
|
62
|
+
* @property {0|3} exitCode The exit code the status implies.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The one completeness verdict over a command context's analysis.
|
|
67
|
+
*
|
|
68
|
+
* The three completeness axes are exactly the envelope law's: a whole-file
|
|
69
|
+
* failure is a file no verdict was reached on; an unresolvable literal site
|
|
70
|
+
* is a question the resolver was asked and could not answer; a zero-analyzed
|
|
71
|
+
* run judged nothing (#599). Dynamic and external sites are declared limits —
|
|
72
|
+
* disclosed in `blindSpots`, never withholding — because
|
|
73
|
+
* `unresolvableLiteralCount` draws that class line once, and this function
|
|
74
|
+
* reads the count rather than re-deriving it.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} commandContext From `resolveCommandContext`.
|
|
77
|
+
* @param {{acceptedFiles?: Set<string>}} [io] `acceptedFiles` names whole-
|
|
78
|
+
* file failures a `coverage.unowned` row already accepts (`waivers` and
|
|
79
|
+
* `check` withdraw those first): the state is a recorded acceptance the
|
|
80
|
+
* report itself names, not a hole the run failed to look at. Absent ⇒ no
|
|
81
|
+
* withdrawal, which is every command but `waivers`.
|
|
82
|
+
* @returns {CoverageVerdict}
|
|
83
|
+
*/
|
|
84
|
+
export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
|
|
85
|
+
const { analysis } = commandContext;
|
|
86
|
+
const notAnalyzed = analysis.failures
|
|
87
|
+
.filter(isWholeFileFailure)
|
|
88
|
+
.filter(({ sourceFile }) => !(acceptedFiles?.has(sourceFile) ?? false))
|
|
89
|
+
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
90
|
+
const blindSpots = blindSpotRows(analysis.failures);
|
|
91
|
+
const blindSpotCount = unresolvableLiteralCount(analysis.failures);
|
|
92
|
+
const complete = notAnalyzed.length === 0 && blindSpotCount === 0 && analysis.analyzed > 0;
|
|
93
|
+
return {
|
|
94
|
+
notAnalyzed,
|
|
95
|
+
blindSpots,
|
|
96
|
+
blindSpotCount,
|
|
97
|
+
complete,
|
|
98
|
+
status: complete ? "ok" : "no-verdict",
|
|
99
|
+
exitCode: complete ? EXIT.ok : EXIT.error,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The structured refusal itself: what a command returns over an analysis it
|
|
105
|
+
* could not fully read, instead of throwing.
|
|
106
|
+
*
|
|
107
|
+
* The envelope carries no `result`: the verdict was withheld, so there is no
|
|
108
|
+
* verdict payload to ride beside the refusal — the `coverage` block IS the
|
|
109
|
+
* payload, and `jsonEnvelope`'s law would refuse any `ok`-shaped claim this
|
|
110
|
+
* object might otherwise smuggle through. `decision` is accepted for the two
|
|
111
|
+
* commands whose envelopes already carry one (`delta`, `change`); an
|
|
112
|
+
* "unknown" decision must name its reason (I3), so callers pass `true` and
|
|
113
|
+
* the reason is joined from the same clause list the text face renders.
|
|
114
|
+
*
|
|
115
|
+
* @param {{command: string, commandContext: object, what: string,
|
|
116
|
+
* acceptedFiles?: Set<string>, decision?: boolean}} input
|
|
117
|
+
* `command` is the envelope's command name, `what` names the question the
|
|
118
|
+
* command was asking (the text face's one free clause), `acceptedFiles`
|
|
119
|
+
* forwards to `coverageVerdict` (see above), and `decision: true` attaches
|
|
120
|
+
* the canonical `unknown` decision.
|
|
121
|
+
* @returns {{status: "no-verdict", coverage: object,
|
|
122
|
+
* report: {text: string, json: string}}}
|
|
123
|
+
*/
|
|
124
|
+
export function coverageRefusal({ command, commandContext, what, acceptedFiles, decision }) {
|
|
125
|
+
const verdict = coverageVerdict(commandContext, { acceptedFiles });
|
|
126
|
+
// A refusal over a complete coverage would be a `no-verdict` claim about a
|
|
127
|
+
// run that looked at everything — the silent direction in the other dress.
|
|
128
|
+
// Every caller gates on `coverageVerdict(...).complete` first; this is the
|
|
129
|
+
// one place that refuses to build the lie if a caller stops gating.
|
|
130
|
+
if (verdict.complete) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`coverageRefusal: ${command}'s coverage is complete — there is nothing to refuse`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
/** @type {"no-verdict"} The completeness is false, so this is the status. */
|
|
136
|
+
const status = "no-verdict";
|
|
137
|
+
const reasons = coverageIncompleteReasons({
|
|
138
|
+
unchecked: verdict.notAnalyzed.length,
|
|
139
|
+
blindSpots: verdict.blindSpotCount,
|
|
140
|
+
analyzed: commandContext.analysis.analyzed,
|
|
141
|
+
});
|
|
142
|
+
const coverage = {
|
|
143
|
+
complete: false,
|
|
144
|
+
projects: Object.keys(commandContext.graph.nodes).length,
|
|
145
|
+
analyzedFiles: commandContext.analysis.analyzed,
|
|
146
|
+
imports: commandContext.analysis.imports.length,
|
|
147
|
+
notAnalyzed: verdict.notAnalyzed,
|
|
148
|
+
blindSpots: verdict.blindSpots,
|
|
149
|
+
notes: [],
|
|
150
|
+
};
|
|
151
|
+
const envelope = jsonEnvelope({
|
|
152
|
+
command,
|
|
153
|
+
context: {
|
|
154
|
+
root: commandContext.root,
|
|
155
|
+
provider: commandContext.provider,
|
|
156
|
+
marker: commandContext.marker,
|
|
157
|
+
provenance: resolveProvenance(commandContext.root),
|
|
158
|
+
},
|
|
159
|
+
status,
|
|
160
|
+
exitCode: verdict.exitCode,
|
|
161
|
+
coverage,
|
|
162
|
+
result: undefined,
|
|
163
|
+
...(decision === true
|
|
164
|
+
? {
|
|
165
|
+
decision: buildDecision({
|
|
166
|
+
status,
|
|
167
|
+
coverageComplete: false,
|
|
168
|
+
findings: 0,
|
|
169
|
+
reason: reasons.join("; "),
|
|
170
|
+
}),
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
status,
|
|
176
|
+
coverage,
|
|
177
|
+
report: {
|
|
178
|
+
text:
|
|
179
|
+
`${command}: no verdict — ${what} needs a workspace this run could fully read\n` +
|
|
180
|
+
formatCoverageIncomplete(reasons),
|
|
181
|
+
json: renderJson(envelope),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/commands/debt.mjs
CHANGED
|
@@ -45,12 +45,13 @@
|
|
|
45
45
|
* when the ledger was taken; the aging itself is snapshot-relative
|
|
46
46
|
* (`../governance/debt-ledger.mjs`).
|
|
47
47
|
*/
|
|
48
|
-
import {
|
|
48
|
+
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
49
49
|
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
50
50
|
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
51
51
|
import { computeDebtLedger } from "../governance/debt-ledger.mjs";
|
|
52
52
|
import { readEvents } from "../governance/evolution-store.mjs";
|
|
53
53
|
import { formatDebtReport } from "../report/debt-text.mjs";
|
|
54
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
54
55
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
55
56
|
import { resolveProvenance } from "./provenance.mjs";
|
|
56
57
|
import { buildObserved, refuseIncompleteGraph } from "./drift.mjs";
|
|
@@ -75,9 +76,12 @@ import { readSnapshots } from "./history.mjs";
|
|
|
75
76
|
* the `history`/`drift` pattern. `events` is the event-store directory the
|
|
76
77
|
* `--events <dir>` CLI flag resolves; absent ⇒ no lifecycle refs (the
|
|
77
78
|
* ledger says so in `lifecycle.note`), never guessed.
|
|
78
|
-
* @returns {Promise<{status: "ok", ledger
|
|
79
|
+
* @returns {Promise<{status: "ok"|"no-verdict", ledger?: object, coverage: object,
|
|
79
80
|
* report: {text: string, json: string}}>}
|
|
80
|
-
*
|
|
81
|
+
* `status: "no-verdict"` carries no `ledger` payload — the verdict was
|
|
82
|
+
* withheld, and the envelope's `coverage` block is the whole answer (#608).
|
|
83
|
+
* @throws {Error} on every condition the header lists except the coverage one,
|
|
84
|
+
* which returns instead of throwing.
|
|
81
85
|
*/
|
|
82
86
|
export async function debtCommand(dir, commandContext, options = {}) {
|
|
83
87
|
const { root, provider, marker, analysis } = commandContext;
|
|
@@ -86,15 +90,16 @@ export async function debtCommand(dir, commandContext, options = {}) {
|
|
|
86
90
|
|
|
87
91
|
refuseIncompleteGraph(commandContext);
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
// The ledger is a claim about the tree the run read, refused through the one
|
|
94
|
+
// structured contract `./coverage-verdict.mjs` builds (#608). This gate used
|
|
95
|
+
// to check whole-file failures only and then claim `coverage.complete: true`
|
|
96
|
+
// beside `blindSpots` that could carry an unjudged site — which the envelope
|
|
97
|
+
// law refuses as a programming error, so a blind-spot-only tree crashed
|
|
98
|
+
// here. The unified completeness refuses it as the verdict-withholding state
|
|
99
|
+
// it is, before the snapshot directory is read.
|
|
100
|
+
const completeness = coverageVerdict(commandContext);
|
|
101
|
+
if (!completeness.complete) {
|
|
102
|
+
return coverageRefusal({ command: "debt", commandContext, what: "aging the debt ledger" });
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
// The snapshot directory is the sole source of truth for aging; a directory
|
|
@@ -173,9 +178,7 @@ export async function debtCommand(dir, commandContext, options = {}) {
|
|
|
173
178
|
analyzedFiles: analysis.analyzed,
|
|
174
179
|
imports: analysis.imports.length,
|
|
175
180
|
notAnalyzed: [],
|
|
176
|
-
blindSpots: analysis.failures
|
|
177
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
178
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
181
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
179
182
|
// The ledger names what it could not age — a reader can tell "aged" from
|
|
180
183
|
// "observed, not yet aged". The second note discloses `sampleTime`'s own
|
|
181
184
|
// nature: it reflects the wall clock at the moment of THIS run, not the
|
|
@@ -593,20 +593,11 @@ export function deltaFindings(delta) {
|
|
|
593
593
|
};
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
* use — a second spelling somewhere would be a second definition of "same
|
|
602
|
-
* edge", and two definitions drift.
|
|
603
|
-
*
|
|
604
|
-
* @param {{source: string, target: string, type: string}} edge
|
|
605
|
-
* @returns {string}
|
|
606
|
-
*/
|
|
607
|
-
export function edgeEvolutionIdentity({ source, target, type }) {
|
|
608
|
-
return `${source}>${target}:${type}`;
|
|
609
|
-
}
|
|
596
|
+
// The identity spelling's ONE home is `../governance/evolution-event.mjs` —
|
|
597
|
+
// `classifyEvolution` owns it and maps every `observed.edges` entry through
|
|
598
|
+
// it. This re-export keeps delta's importers on the same name; it defines
|
|
599
|
+
// nothing.
|
|
600
|
+
export { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
|
|
610
601
|
|
|
611
602
|
/**
|
|
612
603
|
* The delta event's per-constraint verdict deltas (design §1 `fitness`),
|
|
@@ -675,12 +666,16 @@ export function deltaVerdictDeltas(delta) {
|
|
|
675
666
|
*
|
|
676
667
|
* @param {object} delta The `deltaCommand` result payload.
|
|
677
668
|
* @param {{projects?: {added: string[], removed: string[], changed: string[]},
|
|
678
|
-
* edges?: {added: string
|
|
669
|
+
* edges?: {added: {source: string, target: string, type: string}[],
|
|
670
|
+
* removed: {source: string, target: string, type: string}[]},
|
|
671
|
+
* codeDrift?: boolean}} [signals]
|
|
679
672
|
* The structural-change and drift signals a delta run derives from the two
|
|
680
673
|
* graphs it holds (the graph diff is `diff`'s vocabulary, shared here, never
|
|
681
|
-
* re-derived) — `
|
|
682
|
-
*
|
|
683
|
-
*
|
|
674
|
+
* re-derived) — `edges` carries the raw `{source, target, type}` triples,
|
|
675
|
+
* which `classifyEvolution` maps through `edgeEvolutionIdentity` itself (the
|
|
676
|
+
* identity string is its output spelling, never an input), and `codeDrift`
|
|
677
|
+
* is the delta's computed "provenance advanced, no arch/policy change" fact.
|
|
678
|
+
* Absent signals are empty, so a delta that
|
|
684
679
|
* computed none reads as a violation-only mapping.
|
|
685
680
|
* @returns {{classifications: string[], disposition: "accepted"|"rejected"|"no-verdict",
|
|
686
681
|
* notes: string[], affected: {projects: string[], boundaries: string[],
|
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
|
|