@ecoma-io/archkeep 0.13.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/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- package/src/workspace.mjs +580 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `health` command: per-metric verdicts with the
|
|
3
|
+
* number each was decided over, and the trend when a snapshot directory was
|
|
4
|
+
* given.
|
|
5
|
+
*
|
|
6
|
+
* Each metric renders as a verdict word (`ok` / `findings` /
|
|
7
|
+
* `not_applicable` / `unknown`) and — exactly when the metric MEASURED
|
|
8
|
+
* something — the number behind it. A metric whose verdict is
|
|
9
|
+
* `not_applicable` or `unknown` carries no number, because a number with no
|
|
10
|
+
* evidence would read as a measured zero (`../../../../AGENTS.md`: an empty
|
|
11
|
+
* result is a claim, not a shrug). The report states what each
|
|
12
|
+
* `not_applicable` and each `unknown` could not measure, so nothing reads as a
|
|
13
|
+
* silent gap.
|
|
14
|
+
*
|
|
15
|
+
* Determinism: the metric order is fixed, and the trend rows are the snapshots
|
|
16
|
+
* in byte-sort order (`../commands/history.mjs` orders them). This module
|
|
17
|
+
* decides nothing — a formatter that filtered would be a rule wearing a
|
|
18
|
+
* formatter's name (`../README.md`).
|
|
19
|
+
*
|
|
20
|
+
* Three pieces below are exported rather than private, because `report`
|
|
21
|
+
* renders the same metrics inside a larger document (`./report-text.mjs`) and
|
|
22
|
+
* a second copy of "how a metric renders" is how the two faces would come to
|
|
23
|
+
* disagree about the same verdict — the rule against stating a rule twice
|
|
24
|
+
* (`../../../../AGENTS.md`), applied to a format. The metric ORDER travels
|
|
25
|
+
* with them for the same reason: the order is part of the determinism claim,
|
|
26
|
+
* not a per-renderer taste.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The metric line: the verdict word aligned to a column, the number exactly
|
|
31
|
+
* when the metric measured one, and the note that says what an unmeasured
|
|
32
|
+
* metric could not look at.
|
|
33
|
+
*
|
|
34
|
+
* `metric.value` is present ONLY for `ok`/`findings`
|
|
35
|
+
* (`../governance/metrics.mjs` fixes that), so this function never has to
|
|
36
|
+
* decide whether a number may be shown — it shows whatever the metric
|
|
37
|
+
* carries, and a `not_applicable`/`unknown` metric carries none.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} label The metric's display name.
|
|
40
|
+
* @param {{verdict: string, value?: number, note?: string}} metric
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function formatMetricLine(label, metric) {
|
|
44
|
+
const value = metric.value === undefined ? "" : ` ${metric.value}`;
|
|
45
|
+
const note = metric.note ? ` (${metric.note})` : "";
|
|
46
|
+
return ` ${metric.verdict.padEnd(16)}${label}${value}${note}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The fixed metric order: which key of the metrics object each line renders,
|
|
51
|
+
* and under what label. Fixed is the point — two runs over an unchanged tree
|
|
52
|
+
* produce byte-identical reports only if nothing decides this order at render
|
|
53
|
+
* time.
|
|
54
|
+
*
|
|
55
|
+
* @type {readonly {readonly key: string, readonly label: string}[]}
|
|
56
|
+
*/
|
|
57
|
+
export const HEALTH_METRIC_ORDER = Object.freeze([
|
|
58
|
+
Object.freeze({ key: "projects", label: "projects" }),
|
|
59
|
+
Object.freeze({ key: "edges", label: "edges" }),
|
|
60
|
+
Object.freeze({ key: "coverage", label: "coverage" }),
|
|
61
|
+
Object.freeze({ key: "violations", label: "violations" }),
|
|
62
|
+
Object.freeze({ key: "waiverSurface", label: "waiver surface" }),
|
|
63
|
+
Object.freeze({ key: "cycles", label: "cycles" }),
|
|
64
|
+
Object.freeze({ key: "edgeDensity", label: "edge density" }),
|
|
65
|
+
Object.freeze({ key: "debt", label: "debt rows" }),
|
|
66
|
+
Object.freeze({ key: "fitness", label: "intent fitness" }),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The coverage headline: what the run inspected, and — when it could not
|
|
71
|
+
* inspect everything — that the metrics which needed the missing evidence read
|
|
72
|
+
* `unknown` rather than zero.
|
|
73
|
+
*
|
|
74
|
+
* `subject` names the command the headline belongs to, so `report` states its
|
|
75
|
+
* own coverage in the same words `health` does without a second copy of them.
|
|
76
|
+
*
|
|
77
|
+
* @param {{complete: boolean, imports: number, analyzedFiles: number,
|
|
78
|
+
* projects: number, notAnalyzed: object[]}} coverage
|
|
79
|
+
* @param {string} [subject]
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function formatCoverageHeadline(coverage, subject = "health") {
|
|
83
|
+
const inspected =
|
|
84
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
85
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
86
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
87
|
+
|
|
88
|
+
if (coverage.complete) return `✔ ${subject} over complete coverage (${inspected})`;
|
|
89
|
+
|
|
90
|
+
const notAnalyzedCount = coverage.notAnalyzed.length;
|
|
91
|
+
return (
|
|
92
|
+
`✖ ${subject} over incomplete coverage — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
|
|
93
|
+
`could not be analyzed, so the metrics that needed them read unknown (${inspected})`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The whole health report.
|
|
99
|
+
*
|
|
100
|
+
* @param {{metrics: object, trends: object|null, coverage: object}} input
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function formatHealthReport({ metrics, trends, coverage }) {
|
|
104
|
+
const sections = [formatCoverageHeadline(coverage, "health")];
|
|
105
|
+
|
|
106
|
+
for (const { key, label } of HEALTH_METRIC_ORDER) {
|
|
107
|
+
sections.push(formatMetricLine(label, metrics[key]));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (trends) {
|
|
111
|
+
sections.push(
|
|
112
|
+
`trends ${trends.snapshots.length} snapshot${trends.snapshots.length === 1 ? "" : "s"}`,
|
|
113
|
+
);
|
|
114
|
+
for (const snapshot of trends.snapshots) {
|
|
115
|
+
sections.push(
|
|
116
|
+
` ${snapshot.name} ${snapshot.projects} projects, ${snapshot.dependencies} edges`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
for (const note of trends.notes) sections.push(` ${note}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return sections.join("\n");
|
|
123
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `history` command: snapshots in history order,
|
|
3
|
+
* each transition classified, and what the whole record can and cannot say.
|
|
4
|
+
*
|
|
5
|
+
* The summary line states what the record is a claim about — how many
|
|
6
|
+
* snapshots, how many transitions, and the shape of the history the directory
|
|
7
|
+
* actually holds. Counts end every section, so a reader is never left deciding
|
|
8
|
+
* whether an omission is content or silence.
|
|
9
|
+
*
|
|
10
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
11
|
+
* wearing a formatter's name (`../README.md`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Neutralises control and terminal-escape sequences in a name or value before
|
|
16
|
+
* it is printed, so a crafted project/tag/edge name cannot inject escape
|
|
17
|
+
* sequences into a consumer's terminal (`SECURITY.md`). Real project names are
|
|
18
|
+
* ordinary characters and pass through untouched; only C0 control characters
|
|
19
|
+
* (which includes the ESC byte) and DEL become visible escapes.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function sanitize(text) {
|
|
25
|
+
// eslint-disable-next-line no-control-regex
|
|
26
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
27
|
+
if (c === "\n") return "\\n";
|
|
28
|
+
if (c === "\t") return "\\t";
|
|
29
|
+
if (c === "\r") return "\\r";
|
|
30
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One project as a line, same shape as `graph-text.mjs`.
|
|
36
|
+
*
|
|
37
|
+
* @param {{name: string, root: string, tags: string[]}} project
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function formatProject(project) {
|
|
41
|
+
const tags =
|
|
42
|
+
project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
|
|
43
|
+
return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One edge as a line, same shape as `graph-text.mjs`.
|
|
48
|
+
*
|
|
49
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function formatEdge(edge) {
|
|
53
|
+
return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One metadata change as a line, same shape as `diff-text.mjs`.
|
|
58
|
+
*
|
|
59
|
+
* @param {{field: string, baseline: *, head: *}} change
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function formatChange(change) {
|
|
63
|
+
const formatValue = (v) => {
|
|
64
|
+
if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
|
|
65
|
+
if (v === null || v === undefined) return "(none)";
|
|
66
|
+
return sanitize(String(v));
|
|
67
|
+
};
|
|
68
|
+
return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How the architecture actually changed between two snapshots: the added and
|
|
73
|
+
* removed projects and edges rendered as one line each. Changed projects
|
|
74
|
+
* render their changed fields beneath the project line, like `diff`.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} changes The `computeDiff` payload.
|
|
77
|
+
* @returns {string[]}
|
|
78
|
+
*/
|
|
79
|
+
function formatChanges(changes) {
|
|
80
|
+
const lines = [];
|
|
81
|
+
if (changes.addedProjects.length > 0) {
|
|
82
|
+
const word = changes.addedProjects.length === 1 ? "project" : "projects";
|
|
83
|
+
lines.push(`+ ${changes.addedProjects.length} added ${word}`);
|
|
84
|
+
for (const project of changes.addedProjects) lines.push(formatProject(project));
|
|
85
|
+
}
|
|
86
|
+
if (changes.removedProjects.length > 0) {
|
|
87
|
+
const word = changes.removedProjects.length === 1 ? "project" : "projects";
|
|
88
|
+
lines.push(`- ${changes.removedProjects.length} removed ${word}`);
|
|
89
|
+
for (const project of changes.removedProjects) lines.push(formatProject(project));
|
|
90
|
+
}
|
|
91
|
+
if (changes.changedProjects.length > 0) {
|
|
92
|
+
const word = changes.changedProjects.length === 1 ? "project" : "projects";
|
|
93
|
+
lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
|
|
94
|
+
for (const project of changes.changedProjects) {
|
|
95
|
+
lines.push(` ${project.name}`);
|
|
96
|
+
for (const change of project.changes) lines.push(formatChange(change));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (changes.addedEdges.length > 0) {
|
|
100
|
+
const word = changes.addedEdges.length === 1 ? "edge" : "edges";
|
|
101
|
+
lines.push(`+ ${changes.addedEdges.length} added ${word}`);
|
|
102
|
+
for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
|
|
103
|
+
}
|
|
104
|
+
if (changes.removedEdges.length > 0) {
|
|
105
|
+
const word = changes.removedEdges.length === 1 ? "edge" : "edges";
|
|
106
|
+
lines.push(`- ${changes.removedEdges.length} removed ${word}`);
|
|
107
|
+
for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
|
|
108
|
+
}
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Classifies one transition into the short "kind" a reader skims for.
|
|
114
|
+
*
|
|
115
|
+
* @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
|
|
116
|
+
* providerChanged: boolean}} transition
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
function transitionKind(transition) {
|
|
120
|
+
if (transition.architectureChanged) return "architecture";
|
|
121
|
+
if (transition.providerChanged) return "provider";
|
|
122
|
+
if (transition.policyChanged === true) return "policy";
|
|
123
|
+
if (transition.codeDrift) return "code drift";
|
|
124
|
+
return "unchanged";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The whole history report.
|
|
129
|
+
*
|
|
130
|
+
* @param {{evolution: {dir: string, captured: object|null,
|
|
131
|
+
* snapshots: {name: string, id: string}[],
|
|
132
|
+
* transitions: {from: string, to: string, architectureChanged: boolean,
|
|
133
|
+
* changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
|
|
134
|
+
* codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function formatHistoryReport({ evolution, coverage }) {
|
|
138
|
+
const sections = [];
|
|
139
|
+
|
|
140
|
+
const transitionWord = evolution.transitions.length === 1 ? "transition" : "transitions";
|
|
141
|
+
const snapshotWord = evolution.snapshots.length === 1 ? "snapshot" : "snapshots";
|
|
142
|
+
const inspected = `${coverage.imports} import${
|
|
143
|
+
coverage.imports === 1 ? "" : "s"
|
|
144
|
+
} in ${coverage.analyzedFiles} file${
|
|
145
|
+
coverage.analyzedFiles === 1 ? "" : "s"
|
|
146
|
+
} across ${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
147
|
+
|
|
148
|
+
sections.push(`history ${evolution.dir}`);
|
|
149
|
+
sections.push(
|
|
150
|
+
`${evolution.snapshots.length} ${snapshotWord}, ${evolution.transitions.length} ${transitionWord} (${inspected})`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (evolution.captured) {
|
|
154
|
+
sections.push(
|
|
155
|
+
evolution.captured.duplicate
|
|
156
|
+
? `capture ${evolution.captured.name} — already the last snapshot, no new file written`
|
|
157
|
+
: `capture ${evolution.captured.name} written`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const [i, snapshot] of evolution.snapshots.entries()) {
|
|
162
|
+
sections.push(`${i} ${snapshot.name} ${snapshot.id.slice(0, 8)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The footer counts true architectural change only — a transition classified
|
|
166
|
+
// as policy or provider is a change to the record's interpretation, not to
|
|
167
|
+
// the architecture, so a policy-only history must not read as "N transitions
|
|
168
|
+
// recorded an architectural change".
|
|
169
|
+
let changed = 0;
|
|
170
|
+
for (const transition of evolution.transitions) {
|
|
171
|
+
if (transition.architectureChanged) changed += 1;
|
|
172
|
+
const kind = transitionKind(transition);
|
|
173
|
+
sections.push(`~ ${transition.from} → ${transition.to} (${kind})`);
|
|
174
|
+
if (transition.changes) {
|
|
175
|
+
for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
|
|
176
|
+
}
|
|
177
|
+
for (const note of transition.notes) {
|
|
178
|
+
sections.push(` ${note}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (evolution.transitions.length === 0) {
|
|
183
|
+
// A single snapshot is a claim about a history of length one — it says so
|
|
184
|
+
// rather than reporting "no changes", which would be ambiguous.
|
|
185
|
+
sections.push(
|
|
186
|
+
"✔ one snapshot, no transitions yet — capture again after an architectural change",
|
|
187
|
+
);
|
|
188
|
+
} else if (changed === 0) {
|
|
189
|
+
const anySignal = evolution.transitions.some(
|
|
190
|
+
(t) => t.policyChanged === true || t.providerChanged || t.codeDrift,
|
|
191
|
+
);
|
|
192
|
+
sections.push(
|
|
193
|
+
anySignal
|
|
194
|
+
? "✔ no architectural change recorded across the snapshots (only policy, provider, or drift signals)"
|
|
195
|
+
: "✔ no architectural change recorded across the snapshots",
|
|
196
|
+
);
|
|
197
|
+
} else {
|
|
198
|
+
sections.push(
|
|
199
|
+
`${changed} transition${changed === 1 ? "" : "s"} recorded an architectural change`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return sections.join("\n");
|
|
204
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `impact` command: reverse reachability from the
|
|
3
|
+
* project graph.
|
|
4
|
+
*
|
|
5
|
+
* Each dependent project is listed as a single line, grouped under the target
|
|
6
|
+
* project. A project with no dependents renders the header and a "0" count
|
|
7
|
+
* rather than an omitted section, because "nothing depends on this" and "the
|
|
8
|
+
* renderer forgot" look identical otherwise — the same reasoning as
|
|
9
|
+
* `./graph-text.mjs`'s `(no dependencies)` line.
|
|
10
|
+
*
|
|
11
|
+
* When the impact carries a `constraintImpact` field (computed when a boundary
|
|
12
|
+
* config was provided), a constraint-context section follows the dependent
|
|
13
|
+
* listing: each dependent is shown with a ✔ or ✖ marker, the constraint rows
|
|
14
|
+
* that govern its edge, and any violations. When there are no dependents, the
|
|
15
|
+
* section states that explicitly. This section is absent when no config was
|
|
16
|
+
* given, so an impact without `--config` is unchanged from its prior output.
|
|
17
|
+
*
|
|
18
|
+
* The coverage claim sits ABOVE the listing, not below it, so the reader knows
|
|
19
|
+
* whether the result is complete before reading any entry — an incomplete
|
|
20
|
+
* impact set printed in full would have the "this may under-represent" warning
|
|
21
|
+
* buried at the bottom.
|
|
22
|
+
*
|
|
23
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
24
|
+
* wearing a formatter's name (`../README.md`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The constraint context for one dependent, as text.
|
|
29
|
+
*
|
|
30
|
+
* @param {{project: string, edges: object[], constraintRows: object[], violations: object[]}} entry
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function formatConstraintEntry(entry) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
|
|
36
|
+
if (entry.constraintRows.length === 0) {
|
|
37
|
+
lines.push(` (no matching constraint rows — untagged or unmatched tags)`);
|
|
38
|
+
} else {
|
|
39
|
+
for (const row of entry.constraintRows) {
|
|
40
|
+
const tag = row.sourceTag ?? row.allSourceTags?.join("+") ?? "?";
|
|
41
|
+
const parts = [];
|
|
42
|
+
if (row.onlyDependOnLibsWithTags)
|
|
43
|
+
parts.push(`only [${row.onlyDependOnLibsWithTags.join(", ")}]`);
|
|
44
|
+
if (row.notDependOnLibsWithTags)
|
|
45
|
+
parts.push(`not [${row.notDependOnLibsWithTags.join(", ")}]`);
|
|
46
|
+
lines.push(` [${tag}] ${parts.join(", ")}`);
|
|
47
|
+
if (row.description) lines.push(` description ${row.description}`);
|
|
48
|
+
if (row.remediation) lines.push(` remediation ${row.remediation}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const v of entry.violations) {
|
|
53
|
+
lines.push(` ✖ ${v.source} → ${v.target} ${v.messageId}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole impact report.
|
|
61
|
+
*
|
|
62
|
+
* @param {{impact: {project: string, direct: string[], transitive: string[],
|
|
63
|
+
* dependents: string[], constraintImpact?: object[]}, coverage: object}} input
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function formatImpactReport({ impact, coverage }) {
|
|
67
|
+
const sections = [];
|
|
68
|
+
|
|
69
|
+
// Coverage claim goes FIRST — above the listing — so the reader knows
|
|
70
|
+
// whether the impact set is complete before reading any entry.
|
|
71
|
+
const inspected =
|
|
72
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
73
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
74
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
75
|
+
|
|
76
|
+
if (coverage.complete) {
|
|
77
|
+
sections.push(`✔ impact complete (${inspected})`);
|
|
78
|
+
} else {
|
|
79
|
+
const notAnalyzedCount = coverage.notAnalyzed.length;
|
|
80
|
+
sections.push(
|
|
81
|
+
`✖ impact incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
|
|
82
|
+
`could not be analyzed, so this impact set may under-represent the real architecture (${inspected})`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Header names the target project
|
|
87
|
+
sections.push(`Impact of ${impact.project}`);
|
|
88
|
+
|
|
89
|
+
// List dependents — each on its own line
|
|
90
|
+
if (impact.dependents.length > 0) {
|
|
91
|
+
for (const name of impact.dependents) {
|
|
92
|
+
sections.push(` ${name}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Constraint-impact section: when the boundary config was provided, show
|
|
97
|
+
// which constraint rows govern each dependent's edge and whether it violates.
|
|
98
|
+
// The section appears whenever `constraintImpact` is present — even when
|
|
99
|
+
// empty (no dependents) — so a reader can always tell "config provided, no
|
|
100
|
+
// dependents to judge" from "no config".
|
|
101
|
+
if (impact.constraintImpact) {
|
|
102
|
+
sections.push("Constraint context");
|
|
103
|
+
if (impact.constraintImpact.length === 0) {
|
|
104
|
+
sections.push(" (no dependents to judge against constraint table)");
|
|
105
|
+
}
|
|
106
|
+
for (const entry of impact.constraintImpact) {
|
|
107
|
+
const hasViolations = entry.violations.length > 0;
|
|
108
|
+
const marker = hasViolations ? "✖" : "✔";
|
|
109
|
+
sections.push(` ${marker} ${entry.project}`);
|
|
110
|
+
sections.push(formatConstraintEntry(entry));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Summary line: total, with direct and transitive broken out.
|
|
115
|
+
// A project with 0 dependents states "0 project(s) depend on" — an
|
|
116
|
+
// explicit claim, never silence.
|
|
117
|
+
const total = impact.dependents.length;
|
|
118
|
+
const directCount = impact.direct.length;
|
|
119
|
+
const transitiveCount = impact.transitive.length;
|
|
120
|
+
|
|
121
|
+
const projectWord = total === 1 ? "project" : "projects";
|
|
122
|
+
const verbWord = total === 1 ? "depends" : "depend";
|
|
123
|
+
sections.push(
|
|
124
|
+
`${total} ${projectWord} ${verbWord} on ${impact.project} (direct: ${directCount}, transitive: ${transitiveCount})`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return sections.join("\n");
|
|
128
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The versioned JSON envelope every command's `--format json` wraps its
|
|
3
|
+
* result in — one wrapper, six commands, so a consumer writes one parser
|
|
4
|
+
* rather than one per command. `../../../../docs/reference/json-output.md` is the
|
|
5
|
+
* published contract this module builds; this file is where the contract's
|
|
6
|
+
* three consistency rules are enforced in code rather than left to a
|
|
7
|
+
* docs page a later command author might not read.
|
|
8
|
+
*
|
|
9
|
+
* **This module knows no command's payload.** `jsonEnvelope` takes `result` as
|
|
10
|
+
* an opaque object and writes it through unchanged — `../../cli.mjs`'s `check`
|
|
11
|
+
* builds its own `result.violations`/`result.goWork`/`result.tsconfigPaths`,
|
|
12
|
+
* and a later command builds its own. Payload shape lives with the command
|
|
13
|
+
* that produces it, which is what keeps this file untouched by every command
|
|
14
|
+
* after the first (`../commands/README.md` states the same split one layer
|
|
15
|
+
* down).
|
|
16
|
+
*
|
|
17
|
+
* The three assertions below are the empty-result invariant
|
|
18
|
+
* (`../../../../AGENTS.md`) in executable form, not merely documented: a caller
|
|
19
|
+
* that got one of them wrong would otherwise ship a JSON file claiming a clean
|
|
20
|
+
* run over a tree this tool never finished reading, and nothing downstream
|
|
21
|
+
* would ever see the mistake. They throw rather than degrade on purpose — a
|
|
22
|
+
* mismatch here is a bug in the command that built the envelope, not a fact
|
|
23
|
+
* about the workspace being judged, so it gets a programming error rather than
|
|
24
|
+
* a diagnostic.
|
|
25
|
+
*
|
|
26
|
+
* The optional `decision` field rides the same posture. It carries the
|
|
27
|
+
* canonical 4-state verdict (`../governance/verdict.mjs`) that every
|
|
28
|
+
* governance capability emits and consumes, and its `verdict` must agree
|
|
29
|
+
* with the envelope's `status` the same way `status` and `exitCode` must:
|
|
30
|
+
* `"ok"` implies `"pass"`, `"findings"` implies `"fail"`, `"no-verdict"`
|
|
31
|
+
* implies `"unknown"`. A decision that contradicts its status would make one
|
|
32
|
+
* of them a lie — the exact class of disagreement this module refuses to
|
|
33
|
+
* ship. The same refusal covers the invariant the uncertain verdict carries:
|
|
34
|
+
* an `"unknown"` decision must name the reason no verdict was reached, so a
|
|
35
|
+
* hand-built decision cannot slip a reason-less "no verdict" past the last
|
|
36
|
+
* boundary it crosses. The field is OPTIONAL (absent on every envelope that
|
|
37
|
+
* does not opt in), which is what keeps SCHEMA_VERSION at 2: additive,
|
|
38
|
+
* byte-compatible with every consumer that reads the envelope today.
|
|
39
|
+
*/
|
|
40
|
+
import { verdictForStatus } from "../governance/verdict.mjs";
|
|
41
|
+
import { createRequire } from "node:module";
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
/** @type {{name: string, version: string}} */
|
|
45
|
+
const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The schema version this build writes. An integer, per
|
|
49
|
+
* `docs/reference/json-output.md`'s stability promise: it only moves for a
|
|
50
|
+
* breaking change to the envelope, and a consumer that reads a
|
|
51
|
+
* `schemaVersion` it does not recognise should refuse to parse the rest of
|
|
52
|
+
* the envelope rather than guess — an unrecognised version is a caller
|
|
53
|
+
* reading a contract from the future, not a workspace fact. Nothing in this
|
|
54
|
+
* package reads its own envelope back, so that refusal is advice to the
|
|
55
|
+
* consumer, not a mechanism enforced here.
|
|
56
|
+
*/
|
|
57
|
+
export const SCHEMA_VERSION = 2;
|
|
58
|
+
|
|
59
|
+
/** The one `status`↔`exitCode` mapping every command's envelope must agree with. */
|
|
60
|
+
const EXIT_CODE_FOR_STATUS = Object.freeze({ ok: 0, findings: 1, "no-verdict": 3 });
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds the envelope, asserting the three invariants that keep it from ever
|
|
64
|
+
* claiming more than a run actually established.
|
|
65
|
+
*
|
|
66
|
+
* @param {{command: string, context: {root: string, provider: "nx"|"native"|"moon", marker: string,
|
|
67
|
+
* provenance?: {commit: string, remote: string|null, dirty: boolean}|null},
|
|
68
|
+
* status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
|
|
69
|
+
* coverage: {complete: boolean, projects: number, analyzedFiles: number, imports: number,
|
|
70
|
+
* notAnalyzed: object[], blindSpots: object[], notes: string[], coverageGaps?: object[]},
|
|
71
|
+
* result: object,
|
|
72
|
+
* decision?: {verdict: string, reason?: string, notApplicableReason?: string,
|
|
73
|
+
* sampleTime?: string}}} run
|
|
74
|
+
* @returns {object} The envelope `docs/reference/json-output.md` documents.
|
|
75
|
+
* @throws {Error} when `status` claims `"ok"` over incomplete coverage, when
|
|
76
|
+
* `status` and `exitCode` disagree, when `coverage.complete` disagrees
|
|
77
|
+
* with whether `coverage.notAnalyzed` is empty, or when the optional
|
|
78
|
+
* `decision.verdict` contradicts the envelope's `status`.
|
|
79
|
+
*/
|
|
80
|
+
export function jsonEnvelope({ command, context, status, exitCode, coverage, result, decision }) {
|
|
81
|
+
if (status === "ok" && coverage.complete !== true) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`archkeep: refusing to build a JSON envelope claiming status "ok" over incomplete coverage ` +
|
|
84
|
+
`(coverage.complete: false) — a caller cannot report a clean run over a tree it could not ` +
|
|
85
|
+
`fully read. This is a bug in the command that built this envelope, not a fact about the ` +
|
|
86
|
+
`workspace being judged.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (EXIT_CODE_FOR_STATUS[status] !== exitCode) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`archkeep: refusing to build a JSON envelope where status "${status}" and exitCode ${exitCode} ` +
|
|
92
|
+
`disagree — status "${status}" must carry exitCode ${EXIT_CODE_FOR_STATUS[status]}. A ` +
|
|
93
|
+
`consumer reading a file written by --output has only these two fields to trust; letting ` +
|
|
94
|
+
`them disagree would make one of them a lie.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (coverage.complete !== (coverage.notAnalyzed.length === 0)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`archkeep: refusing to build a JSON envelope where coverage.complete (${coverage.complete}) ` +
|
|
100
|
+
`disagrees with coverage.notAnalyzed (${coverage.notAnalyzed.length} entr${coverage.notAnalyzed.length === 1 ? "y" : "ies"}) ` +
|
|
101
|
+
`— the two must always agree, or a reader checking only one of them could mistake a partial ` +
|
|
102
|
+
`run for a complete one.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
// A bare `null` decision is the same programming error as a hand-built
|
|
106
|
+
// decision that contradicts its status: refuse it with the named message
|
|
107
|
+
// rather than crash on `decision.verdict` with a raw TypeError.
|
|
108
|
+
if (decision !== undefined && (decision === null || typeof decision !== "object")) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`archkeep: refusing to build a JSON envelope where decision is ${decision === null ? "null" : `a ${typeof decision}`} ` +
|
|
111
|
+
`rather than an object — a decision must carry a verdict that agrees with its status. ` +
|
|
112
|
+
`This is a bug in the command that built the envelope.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (decision != null) {
|
|
116
|
+
const implied = verdictForStatus(status);
|
|
117
|
+
if (decision.verdict !== implied) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`archkeep: refusing to build a JSON envelope where decision.verdict "${decision.verdict}" ` +
|
|
120
|
+
`contradicts status "${status}" — status implies ${implied}, and a ` +
|
|
121
|
+
`decision that disagrees with its own status would make one of the two a lie. ` +
|
|
122
|
+
`This is a bug in the command that built the envelope.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
// I3, enforced again at this boundary — `src/governance/evidence.mjs`
|
|
126
|
+
// already guarantees it for the engine path, but a hand-built decision
|
|
127
|
+
// would otherwise ship an "unknown" without the reason I3 requires and
|
|
128
|
+
// only this boundary would ever see the mistake. I2 (findings on "fail")
|
|
129
|
+
// gets no latch here for the same reason I4 gets none: the other
|
|
130
|
+
// invariants already pin every route a "fail" can take — `verdictForStatus`
|
|
131
|
+
// only returns "fail" for a "findings" status, and this module knows no
|
|
132
|
+
// command's payload, so counting findings would mean reaching into
|
|
133
|
+
// `result`. A hand-built "fail" with no findings is still a loud lie
|
|
134
|
+
// (status "findings", exitCode 1, verdict "fail"), never the silent
|
|
135
|
+
// direction.
|
|
136
|
+
if (decision.verdict === "unknown" && decision.reason === undefined) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`archkeep: refusing to build a JSON envelope where an "unknown" decision has no reason — ` +
|
|
139
|
+
`I3: an unknown verdict must say why no verdict was reached, or it reads as a shrug. ` +
|
|
140
|
+
`This is a bug in the command that built the envelope.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
schemaVersion: SCHEMA_VERSION,
|
|
147
|
+
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
148
|
+
command,
|
|
149
|
+
workspace: {
|
|
150
|
+
root: context.root,
|
|
151
|
+
provider: context.provider,
|
|
152
|
+
marker: context.marker,
|
|
153
|
+
provenance: context.provenance ?? null,
|
|
154
|
+
},
|
|
155
|
+
status,
|
|
156
|
+
exitCode,
|
|
157
|
+
coverage,
|
|
158
|
+
result,
|
|
159
|
+
...(decision == null ? {} : { decision }),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The envelope as bytes: two-space indent and a trailing newline, so a file
|
|
165
|
+
* written by `--output` reads the same as every other file this tool writes
|
|
166
|
+
* and diffs cleanly in a pull request.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} envelope From `jsonEnvelope`.
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
export function renderJson(envelope) {
|
|
172
|
+
return `${JSON.stringify(envelope, null, 2)}\n`;
|
|
173
|
+
}
|