@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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `diff` command: two graph snapshots compared.
|
|
3
|
+
*
|
|
4
|
+
* Each section — added projects, removed projects, changed projects, added
|
|
5
|
+
* edges, removed edges — is printed only when it has content, and always ends
|
|
6
|
+
* with a count. A section with zero entries is absent from the report, and the
|
|
7
|
+
* summary line then names "no changes" rather than "0 added, 0 removed" — the
|
|
8
|
+
* two must never look identical, because "no changes" is a claim about a
|
|
9
|
+
* complete comparison while "0 added, 0 removed" would be ambiguous over a
|
|
10
|
+
* partial one (`../../../../AGENTS.md`).
|
|
11
|
+
*
|
|
12
|
+
* The changed-projects section lists projects that exist in both baseline and
|
|
13
|
+
* head but whose metadata (tags, type, root) changed. Each changed project
|
|
14
|
+
* shows the field name and its before → after values.
|
|
15
|
+
*
|
|
16
|
+
* When the diff carries a `ruleImpact` field (computed when a boundary config
|
|
17
|
+
* was provided), a rule-impact section lists violations introduced and
|
|
18
|
+
* resolved by the diff, plus a "no boundary-rule impact" line when the config
|
|
19
|
+
* was provided but no violations changed. This section appears whenever
|
|
20
|
+
* `ruleImpact` is present — even when there are no structural changes — so a
|
|
21
|
+
* reader can always tell "config provided, no impact" from "no config". The
|
|
22
|
+
* section is absent when no config was given, so a diff without `--config` is
|
|
23
|
+
* unchanged from its prior output.
|
|
24
|
+
*
|
|
25
|
+
* `coverage.notes` — provider-mismatch, cross-repository, one-sided-policy,
|
|
26
|
+
* and rule-impact-scope warnings `../commands/diff.mjs` pushes there — fold
|
|
27
|
+
* into the summary line the same way `check`'s text face folds `notes` into
|
|
28
|
+
* its own "inspected" line (`../report/text.mjs`'s `formatReport`): appended
|
|
29
|
+
* as `; note`, matching the presentation `check`'s text face already uses.
|
|
30
|
+
* Before this, a note pushed onto `coverage.notes` — a provider migration
|
|
31
|
+
* across baseline and head, say — rode the JSON envelope but never reached
|
|
32
|
+
* the text a terminal user actually reads.
|
|
33
|
+
*
|
|
34
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
35
|
+
* wearing a formatter's name (`../README.md`).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One metadata change as a line.
|
|
40
|
+
*
|
|
41
|
+
* @param {{field: string, baseline: *, head: *}} change
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function formatChange(change) {
|
|
45
|
+
const formatValue = (v) => {
|
|
46
|
+
if (Array.isArray(v)) return v.length > 0 ? v.join(", ") : "(none)";
|
|
47
|
+
if (v === null || v === undefined) return "(none)";
|
|
48
|
+
return String(v);
|
|
49
|
+
};
|
|
50
|
+
return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One project as a line, same shape as `graph-text.mjs`.
|
|
55
|
+
*
|
|
56
|
+
* @param {{name: string, root: string, tags: string[]}} project
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function formatProject(project) {
|
|
60
|
+
const tags = project.tags.length > 0 ? ` [${project.tags.join(", ")}]` : "";
|
|
61
|
+
return ` ${project.name} ${project.root}${tags}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One edge as a line, same shape as `graph-text.mjs`.
|
|
66
|
+
*
|
|
67
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function formatEdge(edge) {
|
|
71
|
+
return ` ${edge.source} → ${edge.target} (${edge.type})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One constraint-impact violation as a line.
|
|
76
|
+
*
|
|
77
|
+
* @param {{messageId: string, constraint: object|null, source: string, target: string}} violation
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function formatImpactViolation(violation) {
|
|
81
|
+
const tag =
|
|
82
|
+
violation.constraint?.sourceTag ??
|
|
83
|
+
violation.constraint?.allSourceTags?.join("+") ??
|
|
84
|
+
"(no matching constraint)";
|
|
85
|
+
return ` ${violation.source} → ${violation.target} ${violation.messageId} [${tag}]`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The whole diff report.
|
|
90
|
+
*
|
|
91
|
+
* @param {{diff: object, coverage: object}} input
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function formatDiffReport({ diff, coverage }) {
|
|
95
|
+
const sections = [];
|
|
96
|
+
|
|
97
|
+
// Baseline summary
|
|
98
|
+
sections.push(
|
|
99
|
+
`baseline ${diff.baseline.path} — ${diff.baseline.projects} projects, ` +
|
|
100
|
+
`${diff.baseline.edges} edges`,
|
|
101
|
+
);
|
|
102
|
+
sections.push(`head ${diff.head.projects} projects, ${diff.head.edges} edges`);
|
|
103
|
+
|
|
104
|
+
// Policy mismatch warning: when the boundary law changed between the
|
|
105
|
+
// baseline and head runs, rule-impact results may reflect the policy change
|
|
106
|
+
// rather than a structural change.
|
|
107
|
+
if (diff.policyMismatch) {
|
|
108
|
+
sections.push(
|
|
109
|
+
"⚠ policy changed between baseline and head — rule-impact results may reflect the policy change, not a structural change",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasChanges =
|
|
114
|
+
diff.addedProjects.length > 0 ||
|
|
115
|
+
diff.removedProjects.length > 0 ||
|
|
116
|
+
(diff.changedProjects && diff.changedProjects.length > 0) ||
|
|
117
|
+
diff.addedEdges.length > 0 ||
|
|
118
|
+
diff.removedEdges.length > 0;
|
|
119
|
+
|
|
120
|
+
if (hasChanges) {
|
|
121
|
+
if (diff.addedProjects.length > 0) {
|
|
122
|
+
const word = diff.addedProjects.length === 1 ? "project" : "projects";
|
|
123
|
+
sections.push(`+ ${diff.addedProjects.length} added ${word}`);
|
|
124
|
+
for (const project of diff.addedProjects) {
|
|
125
|
+
sections.push(formatProject(project));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (diff.removedProjects.length > 0) {
|
|
130
|
+
const word = diff.removedProjects.length === 1 ? "project" : "projects";
|
|
131
|
+
sections.push(`- ${diff.removedProjects.length} removed ${word}`);
|
|
132
|
+
for (const project of diff.removedProjects) {
|
|
133
|
+
sections.push(formatProject(project));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (diff.changedProjects && diff.changedProjects.length > 0) {
|
|
138
|
+
const word = diff.changedProjects.length === 1 ? "project" : "projects";
|
|
139
|
+
sections.push(`~ ${diff.changedProjects.length} changed ${word}`);
|
|
140
|
+
for (const project of diff.changedProjects) {
|
|
141
|
+
sections.push(` ${project.name}`);
|
|
142
|
+
for (const change of project.changes) {
|
|
143
|
+
sections.push(formatChange(change));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (diff.addedEdges.length > 0) {
|
|
149
|
+
const word = diff.addedEdges.length === 1 ? "edge" : "edges";
|
|
150
|
+
sections.push(`+ ${diff.addedEdges.length} added ${word}`);
|
|
151
|
+
for (const edge of diff.addedEdges) {
|
|
152
|
+
sections.push(formatEdge(edge));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (diff.removedEdges.length > 0) {
|
|
157
|
+
const word = diff.removedEdges.length === 1 ? "edge" : "edges";
|
|
158
|
+
sections.push(`- ${diff.removedEdges.length} removed ${word}`);
|
|
159
|
+
for (const edge of diff.removedEdges) {
|
|
160
|
+
sections.push(formatEdge(edge));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Rule-impact section: violations introduced or resolved by this diff.
|
|
166
|
+
// This section appears whenever the boundary config was provided, so a
|
|
167
|
+
// reader can always tell "config provided, no impact" from "no config".
|
|
168
|
+
if (diff.ruleImpact) {
|
|
169
|
+
const { introduced, resolved } = diff.ruleImpact;
|
|
170
|
+
|
|
171
|
+
if (introduced.length > 0) {
|
|
172
|
+
const word = introduced.length === 1 ? "violation" : "violations";
|
|
173
|
+
sections.push(`⚠ ${introduced.length} boundary ${word} introduced`);
|
|
174
|
+
for (const v of introduced) {
|
|
175
|
+
sections.push(formatImpactViolation(v));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (resolved.length > 0) {
|
|
180
|
+
const word = resolved.length === 1 ? "violation" : "violations";
|
|
181
|
+
sections.push(`✔ ${resolved.length} boundary ${word} resolved`);
|
|
182
|
+
for (const v of resolved) {
|
|
183
|
+
sections.push(formatImpactViolation(v));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (introduced.length === 0 && resolved.length === 0) {
|
|
188
|
+
sections.push("✔ no boundary-rule impact");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The summary line states what was compared, so "no changes" reads as a
|
|
193
|
+
// claim about coverage, not as silence. `coverage.notes` — the same
|
|
194
|
+
// provider/cross-repo/policy/rule-impact-scope warnings the JSON envelope
|
|
195
|
+
// already carries — ride here too, exactly the way `check`'s text face
|
|
196
|
+
// folds its own `notes` into its "inspected" line: appended, never a
|
|
197
|
+
// section a caller could silently omit.
|
|
198
|
+
const inspected =
|
|
199
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
200
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
201
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}` +
|
|
202
|
+
(coverage.notes && coverage.notes.length > 0 ? `; ${coverage.notes.join("; ")}` : "");
|
|
203
|
+
|
|
204
|
+
if (!hasChanges) {
|
|
205
|
+
sections.push(`✔ no changes between baseline and head (${inspected})`);
|
|
206
|
+
} else {
|
|
207
|
+
const totalChanges =
|
|
208
|
+
diff.addedProjects.length +
|
|
209
|
+
diff.removedProjects.length +
|
|
210
|
+
(diff.changedProjects ? diff.changedProjects.length : 0) +
|
|
211
|
+
diff.addedEdges.length +
|
|
212
|
+
diff.removedEdges.length;
|
|
213
|
+
sections.push(
|
|
214
|
+
`${totalChanges} change${totalChanges === 1 ? "" : "s"} between baseline and head (${inspected})`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return sections.join("\n");
|
|
219
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `discover` command: observed facts, and under
|
|
3
|
+
* `--propose` the candidate architecture those facts imply.
|
|
4
|
+
*
|
|
5
|
+
* The coverage claim sits ABOVE everything — the reader knows whether the
|
|
6
|
+
* observations are complete before reading any entry, exactly like
|
|
7
|
+
* `./graph-text.mjs`'s report. The proposal, when present, is rendered below
|
|
8
|
+
* the observations with the proposal-only banner (`proposed`, `not
|
|
9
|
+
* authoritative`) repeated on every line of every candidate, so a reader who
|
|
10
|
+
* scans the report cannot mistake a candidate for a decision.
|
|
11
|
+
*
|
|
12
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
13
|
+
* wearing a formatter's name (`../README.md`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The three confidence markers, in the order the legend prints them. */
|
|
17
|
+
const CONFIDENCE_ORDER = ["high", "medium", "low"];
|
|
18
|
+
|
|
19
|
+
/** One project as a line — the same shape `./graph-text.mjs` prints. */
|
|
20
|
+
function formatProject(project) {
|
|
21
|
+
const type = project.type ? ` ${project.type}` : "";
|
|
22
|
+
const tags = project.tags.length > 0 ? ` [${project.tags.join(", ")}]` : "";
|
|
23
|
+
return ` ${project.name} ${project.root}${type}${tags}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One edge as a line — the same shape `./graph-text.mjs` prints. */
|
|
27
|
+
function formatEdge(edge) {
|
|
28
|
+
return ` → ${edge.target} (${edge.type})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PROPOSAL_BANNER = " [proposed — not authoritative] ";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One proposal candidate as one or more lines, every line carrying the
|
|
35
|
+
* proposal-only banner — never a bare candidate that could be read as a
|
|
36
|
+
* decision.
|
|
37
|
+
*
|
|
38
|
+
* The four candidate lists hold different item shapes, so each is formatted by
|
|
39
|
+
* the function that knows its shape rather than by one switch over a flat
|
|
40
|
+
* array — a candidate's `kind` names its role inside its own list
|
|
41
|
+
* (`"component"` means a shared-directory assertion inside
|
|
42
|
+
* `boundaryAssertions.items`, a `"component"` component inside
|
|
43
|
+
* `components.items`), which is why the lists are never concatenated.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** @param {{name: string, projects: string[], confidence: string}} item */
|
|
47
|
+
function formatComponent(item) {
|
|
48
|
+
const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
|
|
49
|
+
return [
|
|
50
|
+
`${PROPOSAL_BANNER}component ${item.name}${confidence}`,
|
|
51
|
+
` members: ${item.projects.join(", ")}`,
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {{component: string|undefined, source: string, target: string, confidence: string}} item */
|
|
56
|
+
function formatBoundaryAssertion(item) {
|
|
57
|
+
const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
|
|
58
|
+
return item.component !== undefined
|
|
59
|
+
? [`${PROPOSAL_BANNER}boundary: projects of ${item.component} share a role${confidence}`]
|
|
60
|
+
: [
|
|
61
|
+
`${PROPOSAL_BANNER}boundary: ${item.source} and ${item.target} belong to different components${confidence}`,
|
|
62
|
+
` evidence: edge ${item.source} → ${item.target}`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {{axis: string|undefined, values: string[]|undefined, tag: string, members: string[], confidence: string}} item */
|
|
67
|
+
function formatTag(item) {
|
|
68
|
+
const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
|
|
69
|
+
return item.axis !== undefined
|
|
70
|
+
? [
|
|
71
|
+
`${PROPOSAL_BANNER}tag axis ${item.axis}${confidence}`,
|
|
72
|
+
` values: ${(item.values ?? []).join(", ")}`,
|
|
73
|
+
]
|
|
74
|
+
: [
|
|
75
|
+
`${PROPOSAL_BANNER}tag ${item.tag}${confidence}`,
|
|
76
|
+
` members: ${(item.members ?? []).join(", ")}`,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {{kind: "noDependency"|"boundary", source: string, target: string, component: string, evidence: object[], confidence: string}} item */
|
|
81
|
+
function formatRule(item) {
|
|
82
|
+
const confidence = item.confidence ? ` (${item.confidence} confidence)` : "";
|
|
83
|
+
return item.kind === "noDependency"
|
|
84
|
+
? [`${PROPOSAL_BANNER}rule: ${item.source} must not depend on ${item.target}${confidence}`]
|
|
85
|
+
: [
|
|
86
|
+
`${PROPOSAL_BANNER}rule: declare a boundary around ${item.component}${confidence}`,
|
|
87
|
+
` members: ${item.evidence
|
|
88
|
+
.filter((e) => e.kind === "shared-directory")
|
|
89
|
+
.map((e) => e.project)
|
|
90
|
+
.join(", ")}`,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The whole discover report.
|
|
96
|
+
*
|
|
97
|
+
* @param {{discovery: {projects: object[], edges: object[], tags: string[]},
|
|
98
|
+
* proposal: object|null,
|
|
99
|
+
* coverage: object}} input
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatDiscoverReport({ discovery, proposal, coverage }) {
|
|
103
|
+
const sections = [];
|
|
104
|
+
|
|
105
|
+
const inspected =
|
|
106
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
107
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
108
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
109
|
+
|
|
110
|
+
if (coverage.complete) {
|
|
111
|
+
sections.push(`✔ discovery complete (${inspected})`);
|
|
112
|
+
} else {
|
|
113
|
+
const notAnalyzedCount = coverage.notAnalyzed.length;
|
|
114
|
+
sections.push(
|
|
115
|
+
`✖ discovery incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
|
|
116
|
+
`could not be analyzed, so these observations may under-represent the workspace (${inspected})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const projectWord = discovery.projects.length === 1 ? "project" : "projects";
|
|
121
|
+
sections.push(`${discovery.projects.length} ${projectWord}`);
|
|
122
|
+
|
|
123
|
+
const edgesBySource = new Map();
|
|
124
|
+
for (const edge of discovery.edges) {
|
|
125
|
+
let list = edgesBySource.get(edge.source);
|
|
126
|
+
if (!list) {
|
|
127
|
+
list = [];
|
|
128
|
+
edgesBySource.set(edge.source, list);
|
|
129
|
+
}
|
|
130
|
+
list.push(edge);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const project of discovery.projects) {
|
|
134
|
+
sections.push(formatProject(project));
|
|
135
|
+
const edges = edgesBySource.get(project.name);
|
|
136
|
+
if (edges && edges.length > 0) {
|
|
137
|
+
for (const edge of edges) {
|
|
138
|
+
sections.push(formatEdge(edge));
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
sections.push(" (no dependencies)");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const edgeWord = discovery.edges.length === 1 ? "edge" : "edges";
|
|
146
|
+
sections.push(`${discovery.edges.length} ${edgeWord}`);
|
|
147
|
+
|
|
148
|
+
if (discovery.tags.length > 0) {
|
|
149
|
+
sections.push(`tags ${discovery.tags.join(", ")}`);
|
|
150
|
+
} else {
|
|
151
|
+
sections.push("tags (none observed)");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (proposal) {
|
|
155
|
+
sections.push("");
|
|
156
|
+
sections.push("proposed architecture — NOT authoritative, never written");
|
|
157
|
+
if (proposal.unknown) {
|
|
158
|
+
sections.push(" no observed projects — nothing to propose");
|
|
159
|
+
} else {
|
|
160
|
+
const counts = [
|
|
161
|
+
`${proposal.components.total} component${proposal.components.total === 1 ? "" : "s"}`,
|
|
162
|
+
`${proposal.boundaryAssertions.total} boundary ` +
|
|
163
|
+
`assertion${proposal.boundaryAssertions.total === 1 ? "" : "s"}`,
|
|
164
|
+
`${proposal.tagVocabulary.total} tag candidate${proposal.tagVocabulary.total === 1 ? "" : "s"}`,
|
|
165
|
+
`${proposal.rules.total} rule${proposal.rules.total === 1 ? "" : "s"}`,
|
|
166
|
+
];
|
|
167
|
+
const confidenceLegend = CONFIDENCE_ORDER.map(
|
|
168
|
+
(level) => `${level} (${proposal.uncertainty[level]})`,
|
|
169
|
+
).join(" · ");
|
|
170
|
+
sections.push(` ${counts.join(", ")}`);
|
|
171
|
+
sections.push(` confidence: ${confidenceLegend}`);
|
|
172
|
+
|
|
173
|
+
const items = [
|
|
174
|
+
...proposal.components.items.map(formatComponent),
|
|
175
|
+
...proposal.boundaryAssertions.items.map(formatBoundaryAssertion),
|
|
176
|
+
...proposal.tagVocabulary.items.map(formatTag),
|
|
177
|
+
...proposal.rules.items.map(formatRule),
|
|
178
|
+
];
|
|
179
|
+
for (const lines of items) {
|
|
180
|
+
sections.push(...lines);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return sections.join("\n");
|
|
186
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `drift` command: the observed architecture
|
|
3
|
+
* compared against the declared intended one.
|
|
4
|
+
*
|
|
5
|
+
* Each section — one per finding kind, in a fixed taxonomy order — is printed
|
|
6
|
+
* only when it has content, and always ends with a count. A section with zero
|
|
7
|
+
* entries is absent from the report, and the summary line then names "no
|
|
8
|
+
* drift" rather than "0 findings": the two must never look identical, because
|
|
9
|
+
* "no drift" is a claim about a complete comparison while "0 findings" would
|
|
10
|
+
* be ambiguous over a partial one (`../../../../AGENTS.md`). The header
|
|
11
|
+
* states what was compared — the intent fingerprint, the row count, and the
|
|
12
|
+
* observed projects and edges, including how many `implicit` edges were
|
|
13
|
+
* excluded — so "no drift" always reads as a claim about the exact tree and
|
|
14
|
+
* contract the run judged.
|
|
15
|
+
*
|
|
16
|
+
* The taxonomy is the judge's own ten-verb catalogue
|
|
17
|
+
* (`../architecture-intent/judge.mjs`) — the message ids `findings` carry in
|
|
18
|
+
* their `rule`. This report is deterministic as long as the taxonomy list is,
|
|
19
|
+
* and it never re-sorts: the judge already orders findings by total key.
|
|
20
|
+
*
|
|
21
|
+
* `notes` — coverage warnings the judge attaches (`../architecture-intent/judge.mjs`'s
|
|
22
|
+
* `verdict.notes`; today an `optional: true` allowed row the team has not
|
|
23
|
+
* built yet) — fold into the "observed" summary line the same way `check`'s
|
|
24
|
+
* text face folds its own `notes` into its "inspected" line
|
|
25
|
+
* (`../report/text.mjs`'s `formatReport`): appended as `; note`, never a
|
|
26
|
+
* section a caller could silently drop.
|
|
27
|
+
*
|
|
28
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
29
|
+
* wearing a formatter's name (`./README.md`).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The taxonomy in report order. Every rule `judgeIntent` can emit appears here. */
|
|
33
|
+
const TAXONOMY = [
|
|
34
|
+
"intentForbiddenEdge",
|
|
35
|
+
"intentAllowedMissing",
|
|
36
|
+
"projectMissing",
|
|
37
|
+
"projectPresent",
|
|
38
|
+
"projectTagMissing",
|
|
39
|
+
"dependencyForbidden",
|
|
40
|
+
"dependencyNotAllowed",
|
|
41
|
+
"tagDependencyForbidden",
|
|
42
|
+
"intentUnknownProject",
|
|
43
|
+
"intentUnknownTag",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** A one-line heading per finding kind, for the grouped section. */
|
|
47
|
+
const LEAVE_LABEL = new Map([
|
|
48
|
+
["intentForbiddenEdge", "dependencies the intended architecture forbids exist"],
|
|
49
|
+
["intentAllowedMissing", "dependencies the intended architecture allows are not being built"],
|
|
50
|
+
["projectMissing", "projects the intent requires are missing"],
|
|
51
|
+
["projectPresent", "projects the intent forbids are present"],
|
|
52
|
+
["projectTagMissing", "required projects lack required tags"],
|
|
53
|
+
["dependencyForbidden", "dependencies the intent forbids exist"],
|
|
54
|
+
["dependencyNotAllowed", "dependencies that are not allowed exist"],
|
|
55
|
+
["tagDependencyForbidden", "tag-forbidden dependencies exist"],
|
|
56
|
+
["intentUnknownProject", "intent rows name projects the architecture does not have"],
|
|
57
|
+
["intentUnknownTag", "tag rules name tags no project carries"],
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function formatFinding(finding) {
|
|
61
|
+
// Edge findings are compact — the pair is the fact. Presence and tag
|
|
62
|
+
// findings carry no source/target pair (the canonical judge sets both to
|
|
63
|
+
// null there), so their message — which names the project or tag — is the
|
|
64
|
+
// fact.
|
|
65
|
+
switch (finding.rule) {
|
|
66
|
+
case "intentForbiddenEdge":
|
|
67
|
+
case "intentAllowedMissing":
|
|
68
|
+
case "dependencyForbidden":
|
|
69
|
+
case "dependencyNotAllowed":
|
|
70
|
+
case "tagDependencyForbidden":
|
|
71
|
+
return ` ${finding.source} → ${finding.target}`;
|
|
72
|
+
default:
|
|
73
|
+
return ` ${finding.message}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The whole drift report.
|
|
79
|
+
*
|
|
80
|
+
* @param {{findings: {rule: string, source: string|null, target: string|null,
|
|
81
|
+
* message: string}[],
|
|
82
|
+
* intent: {fingerprint: string, rows: number},
|
|
83
|
+
* observed: {projects: number, edges: number, implicitEdges: number},
|
|
84
|
+
* notes?: string[],
|
|
85
|
+
* unresolvedDecisionRefs?: {kind: string, decisionRef: string}[],
|
|
86
|
+
* decisionRefsChecked?: number}} input
|
|
87
|
+
* `unresolvedDecisionRefs` — rows whose `decisionRef` cites no ADR, rule, or
|
|
88
|
+
* fitness record this workspace's registry knows — is a documentation fact,
|
|
89
|
+
* not a drift finding: it is rendered in its own section and never folds
|
|
90
|
+
* into the finding count or the "no drift" claim below. `decisionRefsChecked`
|
|
91
|
+
* is how many rows carry a `decisionRef` at all (resolved or not) — the
|
|
92
|
+
* same "no fact, no claim" distinction `formatGoWork` states: a section
|
|
93
|
+
* appears when the axis was exercised, silence only when it was not, so
|
|
94
|
+
* "every citation resolves" is never confused with "nothing uses the field".
|
|
95
|
+
* `notes` — coverage warnings (an `optional: true` allowed row not yet
|
|
96
|
+
* built) — fold into the "observed" line; empty/absent changes nothing.
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function formatDriftReport({
|
|
100
|
+
findings,
|
|
101
|
+
intent,
|
|
102
|
+
observed,
|
|
103
|
+
notes = [],
|
|
104
|
+
unresolvedDecisionRefs = [],
|
|
105
|
+
decisionRefsChecked = unresolvedDecisionRefs.length,
|
|
106
|
+
}) {
|
|
107
|
+
const sections = [];
|
|
108
|
+
|
|
109
|
+
sections.push(
|
|
110
|
+
`intent ${intent.fingerprint} — ${intent.rows} row${intent.rows === 1 ? "" : "s"}`,
|
|
111
|
+
);
|
|
112
|
+
const excluded =
|
|
113
|
+
observed.implicitEdges > 0
|
|
114
|
+
? ` (${observed.implicitEdges} implicit edge${observed.implicitEdges === 1 ? "" : "s"} excluded)`
|
|
115
|
+
: "";
|
|
116
|
+
// `notes` folds in here, the same "; note" convention `check`'s text face
|
|
117
|
+
// uses for its own coverage notes — appended, not a section a caller could
|
|
118
|
+
// silently drop.
|
|
119
|
+
const notesSuffix = notes.length > 0 ? `; ${notes.join("; ")}` : "";
|
|
120
|
+
sections.push(
|
|
121
|
+
`observed ${observed.projects} project${observed.projects === 1 ? "" : "s"}, ` +
|
|
122
|
+
`${observed.edges} edge${observed.edges === 1 ? "" : "s"}${excluded}${notesSuffix}`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const byId = new Map();
|
|
126
|
+
for (const finding of findings) {
|
|
127
|
+
if (!byId.has(finding.rule)) byId.set(finding.rule, []);
|
|
128
|
+
byId.get(finding.rule).push(finding);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `total` is taken from `findings.length` itself, never accumulated only
|
|
132
|
+
// over the rules this walk visits: a finding whose `rule` the taxonomy does
|
|
133
|
+
// not know must still be counted and rendered, under its own heading,
|
|
134
|
+
// rather than silently dropped from both the report and the "no drift"
|
|
135
|
+
// claim below (the invariant this module is judged against,
|
|
136
|
+
// `../../../../AGENTS.md`).
|
|
137
|
+
const total = findings.length;
|
|
138
|
+
for (const rule of TAXONOMY) {
|
|
139
|
+
const group = byId.get(rule) ?? [];
|
|
140
|
+
if (group.length === 0) continue;
|
|
141
|
+
const word = group.length === 1 ? "finding" : "findings";
|
|
142
|
+
sections.push(`⚠ ${group.length} ${word}: ${LEAVE_LABEL.get(rule)}`);
|
|
143
|
+
for (const finding of group) {
|
|
144
|
+
sections.push(formatFinding(finding));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const knownRules = new Set(TAXONOMY);
|
|
149
|
+
const unclassified = findings.filter((finding) => !knownRules.has(finding.rule));
|
|
150
|
+
if (unclassified.length > 0) {
|
|
151
|
+
const word = unclassified.length === 1 ? "finding" : "findings";
|
|
152
|
+
sections.push(
|
|
153
|
+
`⚠ ${unclassified.length} unclassified ${word}: rule id not in this report's taxonomy`,
|
|
154
|
+
);
|
|
155
|
+
for (const finding of unclassified) {
|
|
156
|
+
sections.push(` [${finding.rule}] ${formatFinding(finding).trimStart()}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const inspected =
|
|
161
|
+
`${observed.projects} project${observed.projects === 1 ? "" : "s"}` +
|
|
162
|
+
` and ${observed.edges} edge${observed.edges === 1 ? "" : "s"}` +
|
|
163
|
+
(observed.implicitEdges > 0 ? ` (${observed.implicitEdges} implicit excluded)` : "");
|
|
164
|
+
|
|
165
|
+
if (total === 0) {
|
|
166
|
+
sections.push(`✔ no drift — the observed architecture matches the intended one (${inspected})`);
|
|
167
|
+
} else {
|
|
168
|
+
sections.push(`${total} drift finding${total === 1 ? "" : "s"} (${inspected})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A separate, non-verdict axis — rendered last, after the drift verdict
|
|
172
|
+
// itself, so a clean "no drift" line never reads as though it also vouches
|
|
173
|
+
// for an unresolvable decisionRef sitting above it. "No fact, no claim": a
|
|
174
|
+
// workspace whose rows carry no decisionRef gets no section at all, but one
|
|
175
|
+
// that DOES use the field and finds every citation clean still gets a
|
|
176
|
+
// stated line — silence there would be indistinguishable from never
|
|
177
|
+
// having checked, the same reasoning `formatGoWork` states for its own axis.
|
|
178
|
+
if (unresolvedDecisionRefs.length > 0) {
|
|
179
|
+
sections.push(
|
|
180
|
+
[
|
|
181
|
+
`⚠ ${unresolvedDecisionRefs.length} intent row${unresolvedDecisionRefs.length === 1 ? "" : "s"} ` +
|
|
182
|
+
`cite${unresolvedDecisionRefs.length === 1 ? "s" : ""} a decisionRef that does not resolve ` +
|
|
183
|
+
`to a known ADR, rule, or fitness record:`,
|
|
184
|
+
...unresolvedDecisionRefs.map(({ kind, decisionRef }) => ` ${kind} — "${decisionRef}"`),
|
|
185
|
+
].join("\n"),
|
|
186
|
+
);
|
|
187
|
+
} else if (decisionRefsChecked > 0) {
|
|
188
|
+
sections.push(
|
|
189
|
+
`✔ every decisionRef citation (${decisionRefsChecked}) resolves to a known ADR, rule, or fitness record`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return sections.join("\n");
|
|
194
|
+
}
|