@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,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the agent architecture planning context: the
|
|
3
|
+
* deterministic facts an agent reasons over before changing a project.
|
|
4
|
+
*
|
|
5
|
+
* The layout mirrors what `context`, `impact`, `graph` and `check` each print,
|
|
6
|
+
* so a reader who has seen those commands recognises the same shapes here. The
|
|
7
|
+
* coverage claim sits ABOVE everything — the reader must know whether the
|
|
8
|
+
* result is complete before reading a single entry, the same reasoning as
|
|
9
|
+
* `./impact-text.mjs`.
|
|
10
|
+
*
|
|
11
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
12
|
+
* wearing a formatter's name (`../README.md`). Every fact here comes from the
|
|
13
|
+
* `result` object the command built; nothing is recomputed, and nothing is
|
|
14
|
+
* guessed. Sections that are empty state so rather than printing nothing — a
|
|
15
|
+
* plan with no violations and a plan the renderer forgot are not the same
|
|
16
|
+
* document (`AGENTS.md`: "an empty result is a claim, not a shrug").
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { formatConstraint } from "./text.mjs";
|
|
20
|
+
|
|
21
|
+
/** Two spaces of indent for detail lines. */
|
|
22
|
+
const DETAIL = " ";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The whole planning-context report.
|
|
26
|
+
*
|
|
27
|
+
* @param {{project: object, coverage: object, unresolvedDecisionRefs?: Set<string>}} input
|
|
28
|
+
* The `result` object the plan command built, plus its coverage.
|
|
29
|
+
* `unresolvedDecisionRefs` — `decisionRef` values a matched constraint row
|
|
30
|
+
* cites that do not resolve to any ADR, rule, or fitness record — is
|
|
31
|
+
* forwarded to `formatConstraint`, the same seam `context`'s report uses.
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function formatPlanContextReport({ project, coverage, unresolvedDecisionRefs }) {
|
|
35
|
+
const sections = [];
|
|
36
|
+
|
|
37
|
+
const inspected =
|
|
38
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
39
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
40
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
41
|
+
|
|
42
|
+
if (coverage.complete) {
|
|
43
|
+
sections.push(`✔ planning context complete (${inspected})`);
|
|
44
|
+
} else {
|
|
45
|
+
const notAnalyzedCount = coverage.notAnalyzed.length;
|
|
46
|
+
sections.push(
|
|
47
|
+
`✖ planning context incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
|
|
48
|
+
`could not be analyzed, so these facts may be against an incomplete graph (${inspected})`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const plan = project.plan;
|
|
53
|
+
|
|
54
|
+
// Target project, tags, policy fingerprint.
|
|
55
|
+
const tagsText = project.tags.length > 0 ? project.tags.join(", ") : "none";
|
|
56
|
+
sections.push(`Project ${project.project}`);
|
|
57
|
+
sections.push(`Tags ${tagsText}`);
|
|
58
|
+
sections.push(`Policy fingerprint ${plan.policyFingerprint}`);
|
|
59
|
+
|
|
60
|
+
// Intent-bearing constraints.
|
|
61
|
+
if (project.constraints.length > 0) {
|
|
62
|
+
const count = project.constraints.length;
|
|
63
|
+
sections.push(`Constraints (${count} row${count === 1 ? "" : "s"} govern this project):`);
|
|
64
|
+
for (const constraint of project.constraints) {
|
|
65
|
+
sections.push(`${DETAIL}${formatConstraint(constraint, unresolvedDecisionRefs)}`);
|
|
66
|
+
if (constraint.description) {
|
|
67
|
+
sections.push(`${DETAIL} description ${constraint.description}`);
|
|
68
|
+
}
|
|
69
|
+
if (constraint.remediation) {
|
|
70
|
+
sections.push(`${DETAIL} remediation ${constraint.remediation}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
sections.push(
|
|
75
|
+
"Constraints (no matching constraint rows — this project's tags match no depConstraints entry)",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Affected projects.
|
|
80
|
+
sections.push("Architecture");
|
|
81
|
+
sections.push(
|
|
82
|
+
`${DETAIL}affected projects ${plan.architecture.targets.length > 0 ? plan.architecture.targets.join(", ") : "(none given — the whole workspace is in scope)"}`,
|
|
83
|
+
);
|
|
84
|
+
sections.push(`${DETAIL}projects ${plan.architecture.projects.length}`);
|
|
85
|
+
sections.push(`${DETAIL}dependencies ${plan.architecture.dependencies.length}`);
|
|
86
|
+
|
|
87
|
+
// Impact, capped with overflow note.
|
|
88
|
+
sections.push("Impact");
|
|
89
|
+
for (const entry of plan.impact) {
|
|
90
|
+
const capNote = entry.hasMore
|
|
91
|
+
? ` (… and ${entry.dependentsTotal - entry.dependents.length} more)`
|
|
92
|
+
: "";
|
|
93
|
+
const word = entry.dependentsTotal === 1 ? "project" : "projects";
|
|
94
|
+
sections.push(
|
|
95
|
+
`${DETAIL}${entry.project}: ${entry.dependentsTotal} ${word} depend${entry.dependentsTotal === 1 ? "s" : ""} on it` +
|
|
96
|
+
` (direct ${entry.direct.length}, transitive ${entry.transitive.length})${capNote}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Violations (scoped reporting, whole-tree verdict).
|
|
101
|
+
sections.push(`Violations (${plan.violations.length} in scope of this change)`);
|
|
102
|
+
if (plan.violations.length === 0) {
|
|
103
|
+
sections.push(
|
|
104
|
+
`${DETAIL}none — the full-workspace rule-engine verdict found no violations in scope`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
for (const violation of plan.violations) {
|
|
108
|
+
const edge =
|
|
109
|
+
`${violation.sourceProject ?? "(no project)"} → ` +
|
|
110
|
+
`${violation.targetProject ?? "(unresolved)"}`;
|
|
111
|
+
sections.push(`${DETAIL}${edge} ${violation.messageId} ${violation.sourceFile}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Drift.
|
|
116
|
+
sections.push("Drift");
|
|
117
|
+
sections.push(`${DETAIL}go.work ${driftText(plan.drift.goWork)}`);
|
|
118
|
+
sections.push(`${DETAIL}tsconfig paths ${driftText(plan.drift.tsconfigPaths)}`);
|
|
119
|
+
|
|
120
|
+
// Canonical Architecture Intent — the same fold `check` and `drift` report.
|
|
121
|
+
const intentCount = plan.intent?.findings.length ?? 0;
|
|
122
|
+
const intentUnresolved = plan.intent?.unresolved.length ?? 0;
|
|
123
|
+
sections.push(`Intent (${plan.intent ? "verified" : "no intent declared"})`);
|
|
124
|
+
if (!plan.intent) {
|
|
125
|
+
sections.push(
|
|
126
|
+
`${DETAIL}towards architecture-intent.json — the workspace declares no intent, so none is judged`,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
const verdict =
|
|
130
|
+
intentCount > 0
|
|
131
|
+
? `${intentCount} finding${intentCount === 1 ? "" : "s"}`
|
|
132
|
+
: intentUnresolved > 0
|
|
133
|
+
? `no-verdict (${intentUnresolved} unresolvable)`
|
|
134
|
+
: "ok";
|
|
135
|
+
sections.push(`${DETAIL}${plan.intent.verdict}: ${verdict} (${plan.intent.rows} rows)`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Verification commands.
|
|
139
|
+
sections.push("Verify after the change");
|
|
140
|
+
for (const command of plan.verify) {
|
|
141
|
+
sections.push(`${DETAIL}${command}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return sections.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One drift section as text: a finding count, or the honest `null` answer —
|
|
149
|
+
* "not judged", which is NOT "clean" (`AGENTS.md`).
|
|
150
|
+
*
|
|
151
|
+
* @param {{checked: boolean, findings: object[]}|null} drift
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function driftText(drift) {
|
|
155
|
+
if (drift === null) return "not judged (no manifest to read)";
|
|
156
|
+
return drift.findings.length === 0
|
|
157
|
+
? "checked, no drift"
|
|
158
|
+
: `${drift.findings.length} finding${drift.findings.length === 1 ? "" : "s"}`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `provenance` command: where this run's facts
|
|
3
|
+
* came from, and which governance rows carry an origin.
|
|
4
|
+
*
|
|
5
|
+
* Two lines state what was inspected before any verdict — the repo state (or
|
|
6
|
+
* its absence) and the row count — so "every row carries an origin" always
|
|
7
|
+
* reads as a claim about a specific, complete surface rather than a bare
|
|
8
|
+
* count. Determinism: every line derives from the static facts
|
|
9
|
+
* `../commands/provenance-command.mjs` already resolved — no wall-clock time
|
|
10
|
+
* and no `localeCompare` enter here, matching every other report renderer.
|
|
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
|
+
/**
|
|
17
|
+
* @param {{establishment: boolean,
|
|
18
|
+
* repo: {commit: string|null, remote: string|null, dirty: boolean|null}|null,
|
|
19
|
+
* rowsTotal: number,
|
|
20
|
+
* unattested: {kind: string, label: string, note: string}[],
|
|
21
|
+
* decisionRefTotal: number,
|
|
22
|
+
* unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[]}} input
|
|
23
|
+
* `decisionRefTotal` is how many governance rows cite a `decisionRef` at
|
|
24
|
+
* all — the resolution section renders only when it is non-zero, the same
|
|
25
|
+
* "no fact, no claim" bargain every optional axis in this tool states.
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function formatProvenanceReport({
|
|
29
|
+
establishment,
|
|
30
|
+
repo,
|
|
31
|
+
rowsTotal,
|
|
32
|
+
unattested,
|
|
33
|
+
decisionRefTotal,
|
|
34
|
+
unresolvedDecisionRefs,
|
|
35
|
+
}) {
|
|
36
|
+
const attestedCount = rowsTotal - unattested.length;
|
|
37
|
+
const text = [];
|
|
38
|
+
text.push(
|
|
39
|
+
establishment
|
|
40
|
+
? `repo ${repo.commit}${repo.dirty ? " (dirty)" : ""}` +
|
|
41
|
+
(repo.remote ? ` — ${repo.remote}` : "")
|
|
42
|
+
: "repo provenance unavailable — not a git repository or git not installed",
|
|
43
|
+
);
|
|
44
|
+
text.push(
|
|
45
|
+
`rows ${rowsTotal} governance row${rowsTotal === 1 ? "" : "s"}, ` +
|
|
46
|
+
`${attestedCount} with an origin, ${unattested.length} without`,
|
|
47
|
+
);
|
|
48
|
+
if (unattested.length > 0) {
|
|
49
|
+
text.push("unattested (no origin recorded — cannot attest):");
|
|
50
|
+
for (const row of unattested) {
|
|
51
|
+
text.push(` ${row.kind}`);
|
|
52
|
+
}
|
|
53
|
+
text.push(`${unattested.length} of them carry no decision behind the rule`);
|
|
54
|
+
} else {
|
|
55
|
+
text.push(
|
|
56
|
+
`✔ every governance row carries an origin — each names who decided ` +
|
|
57
|
+
`on it and with what tool`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (decisionRefTotal > 0) {
|
|
61
|
+
if (unresolvedDecisionRefs.length > 0) {
|
|
62
|
+
text.push("unresolved decisionRefs (cite no known ADR, rule, or fitness record):");
|
|
63
|
+
for (const row of unresolvedDecisionRefs) {
|
|
64
|
+
text.push(` ${row.kind} — "${row.decisionRef}"`);
|
|
65
|
+
}
|
|
66
|
+
text.push(
|
|
67
|
+
`${unresolvedDecisionRefs.length} of ${decisionRefTotal} decisionRef citation` +
|
|
68
|
+
`${decisionRefTotal === 1 ? "" : "s"} do not resolve`,
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
text.push(
|
|
72
|
+
`✔ every decisionRef citation (${decisionRefTotal}) resolves to a known ADR, ` +
|
|
73
|
+
`rule, or fitness record`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return text.join("\n");
|
|
78
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `reconcile` command: the intended model and the
|
|
3
|
+
* observed architecture compared element by element, then — with `--propose` —
|
|
4
|
+
* the ranked candidate list of model edits.
|
|
5
|
+
*
|
|
6
|
+
* Every section states what it is a claim about: the intent fingerprint and
|
|
7
|
+
* row count, the observed project and edge counts (implicit edges excluded and
|
|
8
|
+
* counted), then the divergence in fixed plane order — projects, edges, tags,
|
|
9
|
+
* boundaries, intent rows — each only when it has content and each ending with
|
|
10
|
+
* a count. A clean reconciliation prints "✔ model and reality agree" — never
|
|
11
|
+
* "0 divergences", which would be ambiguous over a partial comparison
|
|
12
|
+
* (`../../../../AGENTS.md`). An `unknown` score prints as such, never as a
|
|
13
|
+
* match.
|
|
14
|
+
*
|
|
15
|
+
* The `--propose` face ends with the ranked candidate list: one line per
|
|
16
|
+
* candidate in list order (severity, then plane, then name — the order
|
|
17
|
+
* `buildRankedCandidates` returned), each carrying its `proposed` /
|
|
18
|
+
* `notAuthoritative` marker explicitly so a reader can never mistake the
|
|
19
|
+
* description of an edit for an applied one.
|
|
20
|
+
*
|
|
21
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
22
|
+
* wearing a formatter's name (`../README.md`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Neutralises control and terminal-escape sequences in a name or value before
|
|
27
|
+
* it is printed — the same `sanitize` `history-text.mjs` uses, so a crafted
|
|
28
|
+
* project/tag/edge name cannot inject escape sequences into a consumer's
|
|
29
|
+
* terminal (`../../../../SECURITY.md`).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} text
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function sanitize(text) {
|
|
35
|
+
// eslint-disable-next-line no-control-regex
|
|
36
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
37
|
+
if (c === "\n") return "\\n";
|
|
38
|
+
if (c === "\t") return "\\t";
|
|
39
|
+
if (c === "\r") return "\\r";
|
|
40
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The planes in report order. */
|
|
45
|
+
const PLANE_ORDER = ["projects", "edges", "tags", "boundaries", "intentRows"];
|
|
46
|
+
|
|
47
|
+
/** A one-line label per plane, for the diverging section. */
|
|
48
|
+
const PLANE_LABEL = new Map([
|
|
49
|
+
["projects", "observed projects the model does not match"],
|
|
50
|
+
["edges", "observed edges the model does not match"],
|
|
51
|
+
["tags", "required-tag and tag-rule divergence"],
|
|
52
|
+
["boundaries", "boundary membership divergence"],
|
|
53
|
+
["intentRows", "intent rows whose statement is not observed"],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** The state symbol a diverging element prints. */
|
|
57
|
+
const STATE_SYMBOL = new Map([
|
|
58
|
+
["unexpected", "+"],
|
|
59
|
+
["absent", "-"],
|
|
60
|
+
["unknown", "?"],
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One diverging element as a line.
|
|
65
|
+
*
|
|
66
|
+
* @param {import("../governance/reconcile-score.mjs").ScoredElement} element
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function formatElement(element) {
|
|
70
|
+
const symbol = STATE_SYMBOL.get(element.state) ?? " ";
|
|
71
|
+
return ` ${symbol} ${sanitize(element.name)} (${sanitize(element.classification)})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One candidate as a line.
|
|
76
|
+
*
|
|
77
|
+
* @param {import("../governance/reconcile-candidates.mjs").CandidateEdit} candidate
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function formatCandidate(candidate) {
|
|
81
|
+
const edit = candidate.edit;
|
|
82
|
+
const where = edit.section ? `${edit.action} in ${edit.section}` : edit.action;
|
|
83
|
+
return ` ${candidate.kind} ${sanitize(candidate.name)} (${sanitize(where)} — ${sanitize(edit.reason)})`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The whole reconcile report.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} input
|
|
90
|
+
* @param {object} input.scores From `reconcileScores`.
|
|
91
|
+
* @param {import("../governance/reconcile-candidates.mjs").CandidateEdit[]|null} input.candidates
|
|
92
|
+
* The ranked candidate list, `null` unless `--propose`.
|
|
93
|
+
* @param {{fingerprint: string, rows: number}} input.intent
|
|
94
|
+
* @param {{projects: number, edges: number, implicitEdges: number}} input.observed
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function formatReconcileReport({ scores, candidates, intent, observed }) {
|
|
98
|
+
const sections = [];
|
|
99
|
+
|
|
100
|
+
sections.push(
|
|
101
|
+
`intent ${intent.fingerprint} — ${intent.rows} row${intent.rows === 1 ? "" : "s"}`,
|
|
102
|
+
);
|
|
103
|
+
const excluded =
|
|
104
|
+
observed.implicitEdges > 0
|
|
105
|
+
? ` (${observed.implicitEdges} implicit edge${observed.implicitEdges === 1 ? "" : "s"} excluded)`
|
|
106
|
+
: "";
|
|
107
|
+
sections.push(
|
|
108
|
+
`observed ${observed.projects} project${observed.projects === 1 ? "" : "s"}, ` +
|
|
109
|
+
`${observed.edges} edge${observed.edges === 1 ? "" : "s"}${excluded}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Iterate every key `scores` actually carries, not only the five this
|
|
113
|
+
// module names in `PLANE_ORDER` — `total` and every rendered section come
|
|
114
|
+
// from that walk, so a plane the label map does not know is named and
|
|
115
|
+
// counted rather than silently dropped from the report and from the
|
|
116
|
+
// "no divergence" claim below (the invariant this module is judged
|
|
117
|
+
// against, `../../../../AGENTS.md`). Known planes still render first, in
|
|
118
|
+
// their fixed order; anything else follows, in the order `scores` carries it.
|
|
119
|
+
const knownPlanes = new Set(PLANE_ORDER);
|
|
120
|
+
const planes = [...PLANE_ORDER, ...Object.keys(scores).filter((key) => !knownPlanes.has(key))];
|
|
121
|
+
|
|
122
|
+
let total = 0;
|
|
123
|
+
for (const plane of planes) {
|
|
124
|
+
const group = scores[plane] ?? [];
|
|
125
|
+
const diverging = group.filter((element) => element.state !== "match");
|
|
126
|
+
if (diverging.length === 0) continue;
|
|
127
|
+
total += diverging.length;
|
|
128
|
+
const word = diverging.length === 1 ? "element" : "elements";
|
|
129
|
+
const label = PLANE_LABEL.get(plane) ?? `unknown plane "${plane}"`;
|
|
130
|
+
sections.push(`⚠ ${diverging.length} ${word}: ${label}`);
|
|
131
|
+
for (const element of diverging) sections.push(formatElement(element));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const inspected =
|
|
135
|
+
`${observed.projects} project${observed.projects === 1 ? "" : "s"}` +
|
|
136
|
+
` and ${observed.edges} edge${observed.edges === 1 ? "" : "s"}` +
|
|
137
|
+
(observed.implicitEdges > 0 ? ` (${observed.implicitEdges} implicit excluded)` : "");
|
|
138
|
+
|
|
139
|
+
if (total === 0) {
|
|
140
|
+
sections.push(
|
|
141
|
+
`✔ no divergence — the observed architecture matches the intended model (${inspected})`,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
sections.push(`${total} divergence${total === 1 ? "" : "s"} (${inspected})`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (candidates) {
|
|
148
|
+
const listLabel = candidates.length === 1 ? "1 candidate" : `${candidates.length} candidates`;
|
|
149
|
+
sections.push(`proposal ${listLabel}, ranked — proposed, not authoritative, never written`);
|
|
150
|
+
for (const candidate of candidates) sections.push(formatCandidate(candidate));
|
|
151
|
+
if (candidates.length > 0) {
|
|
152
|
+
sections.push(
|
|
153
|
+
" — apply none of these without review; architecture-intent.json is untouched",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return sections.join("\n");
|
|
159
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `report` command: the architecture governance
|
|
3
|
+
* document — the health metrics, the waiver surface, the fitness gates, the
|
|
4
|
+
* recorded decisions each governed row cites, the run's provenance, and last
|
|
5
|
+
* the evidence the run could not inspect.
|
|
6
|
+
*
|
|
7
|
+
* Three rules this renderer holds, all of them the empty-result invariant
|
|
8
|
+
* (`../../../../AGENTS.md`) wearing a formatter's clothes:
|
|
9
|
+
*
|
|
10
|
+
* - **A surface that could not be established never renders as a clean
|
|
11
|
+
* zero.** Its verdict word is `unknown` and the reason it carries is
|
|
12
|
+
* printed beside it, and the same fact appears again in the closing
|
|
13
|
+
* `could not inspect` block. A reader skimming either one sees the gap.
|
|
14
|
+
* - **`not_applicable` is loud too, and says why.** "The workspace declared
|
|
15
|
+
* none" and "the run could not look" are different sentences and must never
|
|
16
|
+
* read alike.
|
|
17
|
+
* - **A citation that resolves to nothing is `unknown`, never a pass.** A
|
|
18
|
+
* governed row linking to an ADR the registry knows prints that record and
|
|
19
|
+
* its status; a row citing something unresolvable prints `unknown` with the
|
|
20
|
+
* ref that failed.
|
|
21
|
+
*
|
|
22
|
+
* The metric block is rendered through `./health-text.mjs`'s own
|
|
23
|
+
* `formatMetricLine`/`HEALTH_METRIC_ORDER` rather than a second copy of them,
|
|
24
|
+
* so `health` and `report` can never disagree about how a verdict looks or in
|
|
25
|
+
* what order the metrics come.
|
|
26
|
+
*
|
|
27
|
+
* Determinism: every list here arrives already ordered by the command
|
|
28
|
+
* (`../commands/report.mjs` states which order and why), and this module adds
|
|
29
|
+
* no sort, no clock and no locale-sensitive comparison. It decides nothing —
|
|
30
|
+
* a formatter that filtered would be a rule wearing a formatter's name
|
|
31
|
+
* (`./README.md`).
|
|
32
|
+
*
|
|
33
|
+
* Plain text, not Markdown, deliberately: every identity in this document —
|
|
34
|
+
* a project name, a waiver's glob, an ADR id, a fitness function's name —
|
|
35
|
+
* comes from the workspace, and a Markdown rendering would have to escape
|
|
36
|
+
* each one or silently render it wrong (a `_` in a project name, a `*` in a
|
|
37
|
+
* suppression path). A document that quietly mis-renders the name of the
|
|
38
|
+
* thing it is reporting on is the same class of defect as one that reports
|
|
39
|
+
* nothing.
|
|
40
|
+
*/
|
|
41
|
+
import { HEALTH_METRIC_ORDER, formatCoverageHeadline, formatMetricLine } from "./health-text.mjs";
|
|
42
|
+
|
|
43
|
+
/** A section heading, with a blank line before it. */
|
|
44
|
+
function heading(title) {
|
|
45
|
+
return `\n${title}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The verdict word for a whole surface, aligned to the same column the metric
|
|
50
|
+
* lines use, with the reason a non-measuring verdict must carry.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} label
|
|
53
|
+
* @param {{verdict: string, note?: string|null}} surface
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function surfaceLine(label, surface) {
|
|
57
|
+
const note = surface.note ? ` (${surface.note})` : "";
|
|
58
|
+
return ` ${surface.verdict.padEnd(16)}${label}${note}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The provenance block: where this run's facts came from. An unestablished
|
|
63
|
+
* origin prints as a stated absence, never as a blank line a reader could
|
|
64
|
+
* mistake for a clean commit.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} provenance
|
|
67
|
+
* @param {{root: string, provider: string, marker: string}} context
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
function provenanceLines(provenance, context) {
|
|
71
|
+
const lines = [
|
|
72
|
+
` root ${context.root}`,
|
|
73
|
+
` provider ${context.provider} (${context.marker})`,
|
|
74
|
+
` policy ${provenance.policySource ?? "none declared — no boundary law governs this run"}`,
|
|
75
|
+
];
|
|
76
|
+
if (provenance.established) {
|
|
77
|
+
lines.push(` commit ${provenance.repo.commit}${provenance.repo.dirty ? " (dirty)" : ""}`);
|
|
78
|
+
lines.push(` remote ${provenance.repo.remote ?? "none configured"}`);
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(" commit repo provenance unavailable — this report carries no origin claim");
|
|
81
|
+
}
|
|
82
|
+
const { total, unattested } = provenance.rows;
|
|
83
|
+
lines.push(
|
|
84
|
+
` rows ${total} governed row${total === 1 ? "" : "s"}, ` +
|
|
85
|
+
`${unattested.length} with no origin recorded`,
|
|
86
|
+
);
|
|
87
|
+
for (const row of unattested) {
|
|
88
|
+
lines.push(` unattested ${row.label} — no origin recorded, cannot attest`);
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The waiver block: the counts, then one line per suppression on the table.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} waivers
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
function waiverLines(waivers) {
|
|
100
|
+
// "waivers", not "waiver surface": the metric block above already carries a
|
|
101
|
+
// line by that name, and the two are different facts — the metric counts
|
|
102
|
+
// the violations a suppression currently covers, this section describes the
|
|
103
|
+
// table those suppressions live in. Two labels reading alike is how a
|
|
104
|
+
// reader comes to believe one number answers the other's question.
|
|
105
|
+
const lines = [surfaceLine("waivers", waivers)];
|
|
106
|
+
if (waivers.counts === null) return lines;
|
|
107
|
+
const c = waivers.counts;
|
|
108
|
+
lines.push(
|
|
109
|
+
` ${c.waivers} waiver${c.waivers === 1 ? "" : "s"} ` +
|
|
110
|
+
`(${c.expired} expired, ${c.stale} covering nothing), ` +
|
|
111
|
+
`${c.suppressions} permanent suppression${c.suppressions === 1 ? "" : "s"} ` +
|
|
112
|
+
`hiding ${c.suppressed} violation${c.suppressed === 1 ? "" : "s"}`,
|
|
113
|
+
);
|
|
114
|
+
for (const row of waivers.rows) {
|
|
115
|
+
const expiry = row.expiresAt === null ? "" : ` until ${row.expiresAt}`;
|
|
116
|
+
lines.push(
|
|
117
|
+
` ${row.status.padEnd(10)}${row.path}${expiry} — covers ${row.covered} ` +
|
|
118
|
+
`violation${row.covered === 1 ? "" : "s"}`,
|
|
119
|
+
);
|
|
120
|
+
if (row.reason !== null) lines.push(` reason: ${row.reason}`);
|
|
121
|
+
}
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The fitness block: one line per declared gate, with the ADR(s) binding it.
|
|
127
|
+
*
|
|
128
|
+
* `adrs: null` means the registry could not be read, which is a different
|
|
129
|
+
* statement from `[]` ("no recorded decision binds this gate") and is printed
|
|
130
|
+
* as such.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} fitness
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
function fitnessLines(fitness) {
|
|
136
|
+
const lines = [surfaceLine("fitness gates", fitness)];
|
|
137
|
+
for (const fn of fitness.functions) {
|
|
138
|
+
const bound =
|
|
139
|
+
fn.adrs === null
|
|
140
|
+
? " bound by: unknown — the decision registry could not be read"
|
|
141
|
+
: fn.adrs.length > 0
|
|
142
|
+
? ` bound by: ${fn.adrs.join(", ")}`
|
|
143
|
+
: " bound by: no recorded decision";
|
|
144
|
+
lines.push(` ${fn.verdict.padEnd(16)}${fn.name}${bound}`);
|
|
145
|
+
if (fn.verdict !== "pass" && fn.message !== null) lines.push(` ${fn.message}`);
|
|
146
|
+
}
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The decisions block: the registry, and every governed row that cites one.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} decisions
|
|
154
|
+
* @returns {string[]}
|
|
155
|
+
*/
|
|
156
|
+
function decisionLines(decisions) {
|
|
157
|
+
const lines = [surfaceLine("decisions", decisions)];
|
|
158
|
+
if (decisions.registry.count !== null) {
|
|
159
|
+
lines.push(
|
|
160
|
+
` ${decisions.registry.count} record${decisions.registry.count === 1 ? "" : "s"} in ` +
|
|
161
|
+
`${decisions.registry.dir}/`,
|
|
162
|
+
);
|
|
163
|
+
for (const record of decisions.records) {
|
|
164
|
+
const binds =
|
|
165
|
+
record.bindings.length > 0
|
|
166
|
+
? `binds ${record.bindings.join(", ")}`
|
|
167
|
+
: "binds nothing — not yet enforceable";
|
|
168
|
+
lines.push(` ${record.id} (${record.status}) ${binds}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (decisions.citations.length === 0) {
|
|
172
|
+
lines.push(" no governed row cites a decisionRef");
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
// "does not resolve" is a claim about the REF — that nothing in the
|
|
176
|
+
// workspace answers to it. When the registry itself could not be read, that
|
|
177
|
+
// claim is not one this run established: it could not look. The two get
|
|
178
|
+
// different sentences, for the same reason `unknown` and `not_applicable`
|
|
179
|
+
// do (`count: null` is how the command says the registry was unreadable).
|
|
180
|
+
const registryUnread = decisions.registry.count === null;
|
|
181
|
+
for (const citation of decisions.citations) {
|
|
182
|
+
const target =
|
|
183
|
+
citation.resolution === "adr" && citation.adr !== null
|
|
184
|
+
? `${citation.adr.id} (${citation.adr.status})`
|
|
185
|
+
: citation.resolution === "fitness"
|
|
186
|
+
? `${citation.decisionRef} — a fitness rule this law declares`
|
|
187
|
+
: registryUnread
|
|
188
|
+
? `${citation.decisionRef} — unresolved: the decision registry could not be read`
|
|
189
|
+
: `${citation.decisionRef} — does not resolve`;
|
|
190
|
+
lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
|
|
191
|
+
}
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The whole governance report.
|
|
197
|
+
*
|
|
198
|
+
* @param {{coverage: object, metrics: object, trends: object|null,
|
|
199
|
+
* waivers: object, fitness: object, decisions: object, provenance: object,
|
|
200
|
+
* uninspectable: {surface: string, reason: string}[],
|
|
201
|
+
* context: {root: string, provider: string, marker: string}}} input
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
export function formatGovernanceReport({
|
|
205
|
+
coverage,
|
|
206
|
+
metrics,
|
|
207
|
+
trends,
|
|
208
|
+
waivers,
|
|
209
|
+
fitness,
|
|
210
|
+
decisions,
|
|
211
|
+
provenance,
|
|
212
|
+
uninspectable,
|
|
213
|
+
context,
|
|
214
|
+
}) {
|
|
215
|
+
const sections = [];
|
|
216
|
+
|
|
217
|
+
// The document's own verdict, first: whether it could be established at
|
|
218
|
+
// all. A reader who stops after one line must not read "no verdict" as
|
|
219
|
+
// "healthy".
|
|
220
|
+
sections.push(
|
|
221
|
+
uninspectable.length === 0
|
|
222
|
+
? "architecture governance report — every surface reached a verdict"
|
|
223
|
+
: `architecture governance report — NO VERDICT: ${uninspectable.length} ` +
|
|
224
|
+
`surface${uninspectable.length === 1 ? "" : "s"} could not be inspected`,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
sections.push(heading("provenance"));
|
|
228
|
+
sections.push(...provenanceLines(provenance, context));
|
|
229
|
+
|
|
230
|
+
sections.push(heading("health"));
|
|
231
|
+
sections.push(formatCoverageHeadline(coverage, "report"));
|
|
232
|
+
for (const { key, label } of HEALTH_METRIC_ORDER) {
|
|
233
|
+
sections.push(formatMetricLine(label, metrics[key]));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
sections.push(heading("governance surfaces"));
|
|
237
|
+
sections.push(...waiverLines(waivers));
|
|
238
|
+
sections.push(...fitnessLines(fitness));
|
|
239
|
+
sections.push(...decisionLines(decisions));
|
|
240
|
+
|
|
241
|
+
if (trends) {
|
|
242
|
+
sections.push(heading("trends"));
|
|
243
|
+
sections.push(
|
|
244
|
+
` ${trends.snapshots.length} snapshot${trends.snapshots.length === 1 ? "" : "s"}`,
|
|
245
|
+
);
|
|
246
|
+
for (const snapshot of trends.snapshots) {
|
|
247
|
+
sections.push(
|
|
248
|
+
` ${snapshot.name} ${snapshot.projects} projects, ${snapshot.dependencies} edges`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
for (const note of trends.notes) sections.push(` ${note}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Last, and unconditional: an empty block is the claim "every surface was
|
|
255
|
+
// inspectable", which is exactly what it must mean.
|
|
256
|
+
sections.push(heading("could not inspect"));
|
|
257
|
+
if (uninspectable.length === 0) {
|
|
258
|
+
sections.push(" nothing — every surface in this report reached a verdict");
|
|
259
|
+
} else {
|
|
260
|
+
for (const gap of uninspectable) sections.push(` ${gap.surface}: ${gap.reason}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return sections.join("\n");
|
|
264
|
+
}
|