@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
package/src/verdict.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Both sit here rather than in `../cli.mjs` because two callers need them and
|
|
6
6
|
* only one of the two is the CLI shell: `./commands/check.mjs` words its own
|
|
7
|
-
* `--format json` envelope from `verdictFor`, and
|
|
8
|
-
* takes the process's exit code from the same call.
|
|
9
|
-
* `EXIT` under its own name, so every importer that
|
|
10
|
-
* there keeps working.
|
|
7
|
+
* `--format json` envelope's `status` and `exitCode` from `verdictFor`, and
|
|
8
|
+
* `../cli.mjs`'s `runCheck` takes the process's exit code from the same call.
|
|
9
|
+
* `../cli.mjs` re-exports `EXIT` under its own name, so every importer that
|
|
10
|
+
* already reads it from there keeps working.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { buildDecision } from "./report/evidence.mjs";
|
|
@@ -18,6 +18,29 @@ export const EXIT = Object.freeze({
|
|
|
18
18
|
usage: 2,
|
|
19
19
|
error: 3,
|
|
20
20
|
});
|
|
21
|
+
/**
|
|
22
|
+
* The coverage clauses of a no-verdict reason, spelled once — the strings
|
|
23
|
+
* `verdictFor` joins into `decision.reason` and `check`'s text report renders
|
|
24
|
+
* beside its headline, so the two faces cannot disagree about WHY a run
|
|
25
|
+
* failed to reach a verdict. The clauses cover only the three coverage axes
|
|
26
|
+
* (whole-file failures, unresolved sites, zero analysis); the intent,
|
|
27
|
+
* fitness and custom-rule clauses stay local to `verdictFor` because they
|
|
28
|
+
* name verdict surfaces the coverage counts cannot see.
|
|
29
|
+
*
|
|
30
|
+
* @param {{unchecked: number, blindSpots: number, analyzed: number}} counts
|
|
31
|
+
* @returns {string[]} One clause per failed coverage axis, in pinned order.
|
|
32
|
+
*/
|
|
33
|
+
export function coverageIncompleteReasons({ unchecked, blindSpots, analyzed }) {
|
|
34
|
+
return [
|
|
35
|
+
unchecked > 0
|
|
36
|
+
? `${unchecked} file${unchecked === 1 ? "" : "s"} could not be analyzed — coverage incomplete`
|
|
37
|
+
: null,
|
|
38
|
+
blindSpots > 0
|
|
39
|
+
? `${blindSpots} import site${blindSpots === 1 ? "" : "s"} could not be resolved — coverage incomplete`
|
|
40
|
+
: null,
|
|
41
|
+
analyzed === 0 ? "no file in scope could be analyzed — coverage incomplete" : null,
|
|
42
|
+
].filter(Boolean);
|
|
43
|
+
}
|
|
21
44
|
/**
|
|
22
45
|
* The one place that turns a run's counts into the verdict every format
|
|
23
46
|
* agrees on. `runCheck` uses it for the process's exit code; `check` uses the
|
|
@@ -41,8 +64,12 @@ export const EXIT = Object.freeze({
|
|
|
41
64
|
* with no findings), which makes a regression in this mapping a loud error
|
|
42
65
|
* rather than a silent one.
|
|
43
66
|
*
|
|
44
|
-
* @param {{violations: number, declaredEdgeFindings: number, goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number, intentUnresolved: number, intentUnresolvedDecisionRefs?: number, unchecked: number, fitnessFail?: number, fitnessUnknown?: number, customRuleFail?: number, customRuleUnknown?: number}} counts
|
|
45
|
-
* @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3, decision: object}}
|
|
67
|
+
* @param {{violations: number, declaredEdgeFindings: number, goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number, intentUnresolved: number, intentUnresolvedDecisionRefs?: number, unchecked: number, analyzed: number, blindSpots: number, fitnessFail?: number, fitnessUnknown?: number, customRuleFail?: number, customRuleUnknown?: number}} counts
|
|
68
|
+
* @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3, reasons: string[], decision: object}}
|
|
69
|
+
* `reasons` is the coverage clause list behind this verdict — the
|
|
70
|
+
* could-not-look clauses on the findings and no-verdict lanes, empty on the
|
|
71
|
+
* clean one. `decision.reason` joins it with the intent/fitness/custom
|
|
72
|
+
* clauses where the lane is no-verdict.
|
|
46
73
|
*/
|
|
47
74
|
export function verdictFor({
|
|
48
75
|
violations,
|
|
@@ -53,11 +80,14 @@ export function verdictFor({
|
|
|
53
80
|
intentUnresolved,
|
|
54
81
|
intentUnresolvedDecisionRefs = 0,
|
|
55
82
|
unchecked,
|
|
83
|
+
analyzed,
|
|
84
|
+
blindSpots,
|
|
56
85
|
fitnessFail = 0,
|
|
57
86
|
fitnessUnknown = 0,
|
|
58
87
|
customRuleFail = 0,
|
|
59
88
|
customRuleUnknown = 0,
|
|
60
89
|
}) {
|
|
90
|
+
const coverageReasons = coverageIncompleteReasons({ unchecked, blindSpots, analyzed });
|
|
61
91
|
if (
|
|
62
92
|
violations > 0 ||
|
|
63
93
|
declaredEdgeFindings > 0 ||
|
|
@@ -74,9 +104,10 @@ export function verdictFor({
|
|
|
74
104
|
return {
|
|
75
105
|
status: "findings",
|
|
76
106
|
exitCode: EXIT.violations,
|
|
107
|
+
reasons: coverageReasons,
|
|
77
108
|
decision: buildDecision({
|
|
78
109
|
status: "findings",
|
|
79
|
-
coverageComplete: unchecked === 0,
|
|
110
|
+
coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
|
|
80
111
|
findings:
|
|
81
112
|
violations +
|
|
82
113
|
declaredEdgeFindings +
|
|
@@ -90,52 +121,63 @@ export function verdictFor({
|
|
|
90
121
|
}
|
|
91
122
|
if (
|
|
92
123
|
unchecked > 0 ||
|
|
124
|
+
// An unresolvable site was seen but never judged (#595): named in
|
|
125
|
+
// coverage.blindSpots, and echoed here so the exit says it too — a
|
|
126
|
+
// pass over a site the run could not read claims a verdict it does
|
|
127
|
+
// not hold.
|
|
128
|
+
blindSpots > 0 ||
|
|
129
|
+
// A run that analyzed nothing judged nothing (#599) — a scope that
|
|
130
|
+
// selected no project-owned file, or in-scope files no analyzer
|
|
131
|
+
// claims. Judging nothing is not finding nothing.
|
|
132
|
+
analyzed === 0 ||
|
|
93
133
|
intentUnresolved > 0 ||
|
|
94
134
|
intentUnresolvedDecisionRefs > 0 ||
|
|
95
135
|
fitnessUnknown > 0 ||
|
|
96
136
|
customRuleUnknown > 0
|
|
97
137
|
) {
|
|
138
|
+
// The list is built before the return so `decision.reason` joins the very
|
|
139
|
+
// array the envelope's `reasons` carries — one list, two renderings, and
|
|
140
|
+
// neither can drift from the other.
|
|
141
|
+
const reasons = [
|
|
142
|
+
...coverageReasons,
|
|
143
|
+
// The could-not-look condition, named so a reader knows WHICH half of
|
|
144
|
+
// the run did not reach a verdict (I3). When read-only coverage and
|
|
145
|
+
// intent both failed, name both — a reason naming only the file count
|
|
146
|
+
// would hide the unresolved intent boundary from a reader acting on
|
|
147
|
+
// the reason alone (it stays visible in result.intent.unresolved, and
|
|
148
|
+
// status is still no-verdict, so nothing is silent). Each clause below
|
|
149
|
+
// is independent of the others — none is gated on a sibling clause
|
|
150
|
+
// being zero — so a tree that fails on several axes at once names
|
|
151
|
+
// every one of them, not only the first the array happens to hit.
|
|
152
|
+
intentUnresolved > 0
|
|
153
|
+
? `${intentUnresolved} architecture-intent boundary or row${intentUnresolved === 1 ? "" : "s"} could not be established`
|
|
154
|
+
: null,
|
|
155
|
+
intentUnresolvedDecisionRefs > 0
|
|
156
|
+
? `${intentUnresolvedDecisionRefs} intent row${intentUnresolvedDecisionRefs === 1 ? "" : "s"} ${intentUnresolvedDecisionRefs === 1 ? "cites" : "cite"} a decisionRef that does not resolve`
|
|
157
|
+
: null,
|
|
158
|
+
fitnessUnknown > 0
|
|
159
|
+
? `${fitnessUnknown} fitness functions${fitnessUnknown === 1 ? "" : "s"} could not be determined`
|
|
160
|
+
: null,
|
|
161
|
+
customRuleUnknown > 0
|
|
162
|
+
? `${customRuleUnknown} custom rule${customRuleUnknown === 1 ? "" : "s"} could not be judged`
|
|
163
|
+
: null,
|
|
164
|
+
].filter(Boolean);
|
|
98
165
|
return {
|
|
99
166
|
status: "no-verdict",
|
|
100
167
|
exitCode: EXIT.error,
|
|
168
|
+
reasons,
|
|
101
169
|
decision: buildDecision({
|
|
102
170
|
status: "no-verdict",
|
|
103
|
-
coverageComplete: unchecked === 0,
|
|
171
|
+
coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
|
|
104
172
|
findings: 0,
|
|
105
|
-
|
|
106
|
-
// the run did not reach a verdict (I3). When read-only coverage and
|
|
107
|
-
// intent both failed, name both — a reason naming only the file count
|
|
108
|
-
// would hide the unresolved intent boundary from a reader acting on
|
|
109
|
-
// the reason alone (it stays visible in result.intent.unresolved, and
|
|
110
|
-
// status is still no-verdict, so nothing is silent). Each clause below
|
|
111
|
-
// is independent of the others — none is gated on a sibling clause
|
|
112
|
-
// being zero — so a tree that fails on several axes at once names
|
|
113
|
-
// every one of them, not just the first the array happens to check.
|
|
114
|
-
reason: [
|
|
115
|
-
unchecked > 0
|
|
116
|
-
? `${unchecked} file${unchecked === 1 ? "" : "s"} could not be analyzed — coverage incomplete`
|
|
117
|
-
: null,
|
|
118
|
-
intentUnresolved > 0
|
|
119
|
-
? `${intentUnresolved} architecture-intent boundary or row${intentUnresolved === 1 ? "" : "s"} could not be established`
|
|
120
|
-
: null,
|
|
121
|
-
intentUnresolvedDecisionRefs > 0
|
|
122
|
-
? `${intentUnresolvedDecisionRefs} intent row${intentUnresolvedDecisionRefs === 1 ? "" : "s"} ${intentUnresolvedDecisionRefs === 1 ? "cites" : "cite"} a decisionRef that does not resolve`
|
|
123
|
-
: null,
|
|
124
|
-
fitnessUnknown > 0
|
|
125
|
-
? `${fitnessUnknown} fitness function${fitnessUnknown === 1 ? "" : "s"} could not be determined`
|
|
126
|
-
: null,
|
|
127
|
-
customRuleUnknown > 0
|
|
128
|
-
? `${customRuleUnknown} custom rule${customRuleUnknown === 1 ? "" : "s"} could not be judged`
|
|
129
|
-
: null,
|
|
130
|
-
]
|
|
131
|
-
.filter(Boolean)
|
|
132
|
-
.join("; "),
|
|
173
|
+
reason: reasons.join("; "),
|
|
133
174
|
}),
|
|
134
175
|
};
|
|
135
176
|
}
|
|
136
177
|
return {
|
|
137
178
|
status: "ok",
|
|
138
179
|
exitCode: EXIT.ok,
|
|
180
|
+
reasons: [],
|
|
139
181
|
decision: buildDecision({
|
|
140
182
|
status: "ok",
|
|
141
183
|
coverageComplete: true,
|
package/src/workspace.mjs
CHANGED
|
@@ -598,12 +598,130 @@ export function selectFiles(files, paths, { root, cwd, tracked = files }) {
|
|
|
598
598
|
* @param {{ analyze?: typeof analyzeFile }} [io] Injectable analyzer.
|
|
599
599
|
* @returns {{ imports: object[], failures: object[], analyzed: number, analyzedFiles: string[] }}
|
|
600
600
|
*/
|
|
601
|
+
/**
|
|
602
|
+
* Extensions that cannot carry an import or a boundary crossing, so their
|
|
603
|
+
* silence in analysis is never a coverage gap (#601): documentation,
|
|
604
|
+
* structured data and configuration, binary assets, generated lockfiles.
|
|
605
|
+
* A file in one of these formats has no imports for ANY analyzer to read —
|
|
606
|
+
* listing it under "unsupported language" would name every README in every
|
|
607
|
+
* workspace, and a gap that always fires teaches a reader to skip the line
|
|
608
|
+
* it is written on (the argument `./commands/context.mjs`'s
|
|
609
|
+
* `unownedAnalyzableFiles` already states for the same reason). The set must
|
|
610
|
+
* never intersect `LANGUAGE_BY_EXTENSION` — a format that cannot carry an
|
|
611
|
+
* import cannot become an analyzed language; `workspace.test.mjs` holds that
|
|
612
|
+
* line, because an entry that crossed it would turn this exemption into the
|
|
613
|
+
* silent direction.
|
|
614
|
+
*/
|
|
615
|
+
const DATA_BY_EXTENSION = Object.freeze(
|
|
616
|
+
new Set([
|
|
617
|
+
// Documentation.
|
|
618
|
+
".md",
|
|
619
|
+
".txt",
|
|
620
|
+
".rst",
|
|
621
|
+
".adoc",
|
|
622
|
+
// Structured data and configuration.
|
|
623
|
+
".json",
|
|
624
|
+
".json5",
|
|
625
|
+
".jsonc",
|
|
626
|
+
".yaml",
|
|
627
|
+
".yml",
|
|
628
|
+
".toml",
|
|
629
|
+
".ini",
|
|
630
|
+
".cfg",
|
|
631
|
+
".conf",
|
|
632
|
+
".properties",
|
|
633
|
+
".xml",
|
|
634
|
+
// Binary assets.
|
|
635
|
+
".png",
|
|
636
|
+
".jpg",
|
|
637
|
+
".jpeg",
|
|
638
|
+
".gif",
|
|
639
|
+
".svg",
|
|
640
|
+
".webp",
|
|
641
|
+
".ico",
|
|
642
|
+
".bmp",
|
|
643
|
+
".woff",
|
|
644
|
+
".woff2",
|
|
645
|
+
".ttf",
|
|
646
|
+
".otf",
|
|
647
|
+
".eot",
|
|
648
|
+
".mp4",
|
|
649
|
+
".mp3",
|
|
650
|
+
".wav",
|
|
651
|
+
".pdf",
|
|
652
|
+
// Archives and generated artifacts.
|
|
653
|
+
".zip",
|
|
654
|
+
".gz",
|
|
655
|
+
".tgz",
|
|
656
|
+
".tar",
|
|
657
|
+
".br",
|
|
658
|
+
// Generated lockfiles.
|
|
659
|
+
".lock",
|
|
660
|
+
".sum",
|
|
661
|
+
]),
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Extension-less basenames that are workspace furniture, not source: legal
|
|
666
|
+
* notices and the two command files whose bodies are shell commands rather
|
|
667
|
+
* than imports. `languageOf` answers `null` for every dotless name already —
|
|
668
|
+
* this set is what keeps them out of the unsupported-language row without
|
|
669
|
+
* inventing a general dotfile list.
|
|
670
|
+
*/
|
|
671
|
+
const DOTLESS_FURNITURE = Object.freeze(
|
|
672
|
+
new Set([
|
|
673
|
+
"LICENSE",
|
|
674
|
+
"NOTICE",
|
|
675
|
+
"AUTHORS",
|
|
676
|
+
"CHANGELOG",
|
|
677
|
+
"CODEOWNERS",
|
|
678
|
+
"CONTRIBUTING",
|
|
679
|
+
"SECURITY",
|
|
680
|
+
"Makefile",
|
|
681
|
+
"Dockerfile",
|
|
682
|
+
]),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Whether a file no analyzer claims is nevertheless not a coverage gap —
|
|
687
|
+
* the predicate `analyzeWorkspace` applies before naming a skipped file
|
|
688
|
+
* under `unsupported-language` (#601). Three exemptions, each with the same
|
|
689
|
+
* shape of reason: the file was never part of what this tool could judge.
|
|
690
|
+
*
|
|
691
|
+
* @param {string} sourceFile Workspace-relative path.
|
|
692
|
+
* @returns {boolean}
|
|
693
|
+
*/
|
|
694
|
+
export function exemptFromUnsupportedLanguage(sourceFile) {
|
|
695
|
+
const base = sourceFile.slice(sourceFile.lastIndexOf("/") + 1);
|
|
696
|
+
// Dotfiles are editor and tool state (`.gitignore`, `.env`, `.npmrc`).
|
|
697
|
+
if (base.startsWith(".")) return true;
|
|
698
|
+
const dot = base.lastIndexOf(".");
|
|
699
|
+
if (dot > 0 && DATA_BY_EXTENSION.has(base.slice(dot))) return true;
|
|
700
|
+
if (DOTLESS_FURNITURE.has(base)) return true;
|
|
701
|
+
// The polyglot manifests the manifest track itself reads — a `go.mod` is
|
|
702
|
+
// claimed by the engine one layer down, not skipped by it.
|
|
703
|
+
return basenameMatches(base, POLYGLOT_MANIFEST_NAMES, posix.matchesGlob);
|
|
704
|
+
}
|
|
705
|
+
|
|
601
706
|
export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {}) {
|
|
602
707
|
const imports = [];
|
|
603
708
|
const failures = [];
|
|
604
709
|
const analyzedFiles = [];
|
|
710
|
+
// Files whose extension no analyzer claims AND whose silence is a coverage
|
|
711
|
+
// gap (#601): skipped before reading, so naming them here is the only
|
|
712
|
+
// record the run will ever carry that they existed — "not analyzed" must
|
|
713
|
+
// not be indistinguishable from "not present". READMEs, manifests and
|
|
714
|
+
// other formats that cannot carry an import are exempt
|
|
715
|
+
// (`exemptFromUnsupportedLanguage` above): they are not an unsupported
|
|
716
|
+
// language, they are workspace furniture the tool has always declined.
|
|
717
|
+
const unsupportedLanguageFiles = [];
|
|
605
718
|
for (const sourceFile of files) {
|
|
606
|
-
if (languageOf(sourceFile) === null)
|
|
719
|
+
if (languageOf(sourceFile) === null) {
|
|
720
|
+
if (!exemptFromUnsupportedLanguage(sourceFile)) {
|
|
721
|
+
unsupportedLanguageFiles.push(sourceFile);
|
|
722
|
+
}
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
607
725
|
const text = workspace.readFile(sourceFile);
|
|
608
726
|
if (text === null) {
|
|
609
727
|
failures.push(fileFailure(sourceFile, "could not be read"));
|
|
@@ -614,7 +732,13 @@ export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {
|
|
|
614
732
|
imports.push(...result.imports);
|
|
615
733
|
failures.push(...result.failures);
|
|
616
734
|
}
|
|
617
|
-
return {
|
|
735
|
+
return {
|
|
736
|
+
imports,
|
|
737
|
+
failures,
|
|
738
|
+
analyzed: analyzedFiles.length,
|
|
739
|
+
analyzedFiles,
|
|
740
|
+
unsupportedLanguageFiles,
|
|
741
|
+
};
|
|
618
742
|
}
|
|
619
743
|
|
|
620
744
|
/** The polyglot manifests `polyglotManifests` looks for. */
|