@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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Nx project-model provider — the seam's first (and today, only)
|
|
3
|
+
* implementation.
|
|
4
|
+
*
|
|
5
|
+
* `evaluate(importSites, graph, config)` in `../rules/index.mjs` is pure and
|
|
6
|
+
* takes a graph it does not build (`../../AGENTS.md`, "Layout, and what each
|
|
7
|
+
* layer may know"). Something still has to produce that graph, and until this
|
|
8
|
+
* module existed the answer was hardcoded into `../workspace.mjs`: `check()`
|
|
9
|
+
* called `readProjectGraph` and there was nowhere else the graph could come
|
|
10
|
+
* from. This file is that answer pulled out from behind the hardcoding — the
|
|
11
|
+
* `ProjectModelProvider` contract below is what a second source (a
|
|
12
|
+
* `archkeep.json`-driven native model, with no Nx installed at all) will
|
|
13
|
+
* implement beside this one, without `../workspace.mjs`, `../../cli.mjs`, or
|
|
14
|
+
* `../lsp/` needing to know which provider they are holding.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} ProjectModelProvider
|
|
17
|
+
* @property {string} name Short, stable identifier — `"nx"` here — for a
|
|
18
|
+
* diagnostic that needs to say which provider answered (or failed to).
|
|
19
|
+
* @property {(workspaceRoot: string, io?: object) => object} readProjectGraph
|
|
20
|
+
* The workspace root, plus whatever spawns/reads this provider needs
|
|
21
|
+
* injected for a test — and back comes the graph half of the shape
|
|
22
|
+
* `evaluate()` consumes: `{nodes, dependencies}`, plus `externalNodes` and
|
|
23
|
+
* `workspaceLayout` when the provider's source of truth carries them.
|
|
24
|
+
* `nx graph --file=` itself emits neither (see `readProjectGraph` below) —
|
|
25
|
+
* `externalNodes` stays absent, deliberately (`../../AGENTS.md` argues that
|
|
26
|
+
* refusal), but `workspaceLayout` is merged back in from `nx.json` by
|
|
27
|
+
* `readProjectGraph` itself, because that command's own output is not the
|
|
28
|
+
* whole of what the workspace declared. Everything the source DOES emit
|
|
29
|
+
* must travel inside that one object, because a provider that dropped a
|
|
30
|
+
* field its source supplied would force a second call to the same source
|
|
31
|
+
* outside the seam. Three node facts
|
|
32
|
+
* deliberately do NOT come from the provider: `mfeRemote`, `entryPoints`
|
|
33
|
+
* and `declaredPackages` are annotated onto `graph.nodes` by the caller
|
|
34
|
+
* afterwards (`../../cli.mjs`, via `annotateMFERemotes` and
|
|
35
|
+
* `annotatePackageFacts`), read from disk the way upstream reads them — a
|
|
36
|
+
* provider that filled them in would only be overwritten.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
40
|
+
import { createRequire } from "node:module";
|
|
41
|
+
import { tmpdir } from "node:os";
|
|
42
|
+
import { dirname, join } from "node:path";
|
|
43
|
+
|
|
44
|
+
import { runProcess } from "../process.mjs";
|
|
45
|
+
import { readWorkspaceLayout, requireCompleteWorkspaceLayout } from "../options.mjs";
|
|
46
|
+
|
|
47
|
+
const require = createRequire(import.meta.url);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Absolute path to Nx's own CLI entry — the JS file its `nx` bin points at.
|
|
51
|
+
*
|
|
52
|
+
* Spawned under this Node binary rather than through `pnpm nx`, which takes the
|
|
53
|
+
* package manager's platform-specific bin shim out of the picture instead of
|
|
54
|
+
* wrapping it, and leaves no shell for a `TMPDIR` carrying metacharacters to
|
|
55
|
+
* reach. It resolves by Node's own rules from this file's location, and that is
|
|
56
|
+
* correct rather than incidental: `nx` is a **peer** dependency, so what those
|
|
57
|
+
* rules find is the copy the consumer installed — the same version answering
|
|
58
|
+
* `pnpm nx` in their tree — and never a second one bundled in here. A tool that
|
|
59
|
+
* shipped its own Nx could report a graph the workspace's own CLI disagrees with.
|
|
60
|
+
*
|
|
61
|
+
* `nx` is declared `peerDependenciesMeta.nx.optional`, so this resolution is
|
|
62
|
+
* allowed to fail — but `check()` calls `readProjectGraph` unconditionally
|
|
63
|
+
* (`../../cli.mjs`), so a workspace that never installed `nx` gets no working
|
|
64
|
+
* `archkeep check` at all: every run exits 3 with the diagnostic below. The
|
|
65
|
+
* "Installing it" section of `../../README.md` states the same thing for a
|
|
66
|
+
* reader outside this file. What this function must not do on failure is let
|
|
67
|
+
* Node's own
|
|
68
|
+
* `MODULE_NOT_FOUND` reach the caller: that error names a package specifier,
|
|
69
|
+
* not a workspace-facing action, and `cli.mjs` prints `error.message` verbatim
|
|
70
|
+
* on the exit-3 path — a raw resolver stack there would read as a bug in this
|
|
71
|
+
* tool rather than the true cause, an absent peer.
|
|
72
|
+
*
|
|
73
|
+
* `resolveNx` is injectable for the same reason `run` is: this repository has
|
|
74
|
+
* `nx` installed, so nothing here can drive the real "not installed" path
|
|
75
|
+
* without faking the resolver — `nx.test.mjs` does exactly that, over a
|
|
76
|
+
* `resolveNx` that throws the way `require.resolve` does when nothing on disk
|
|
77
|
+
* answers `nx/package.json`. It takes no argument because it only ever
|
|
78
|
+
* resolves that one specifier — accepting one and forwarding it to
|
|
79
|
+
* `require`'s own resolver would hand that call a variable rather than a
|
|
80
|
+
* literal, which is exactly the load `../conformance/boundary.test.mjs`
|
|
81
|
+
* cannot see through and reports as opaque rather than trusting.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ resolveNx?: () => string }} [io]
|
|
84
|
+
* @throws {Error} named `archkeep: nx is not installed`, when `resolveNx`
|
|
85
|
+
* cannot find it.
|
|
86
|
+
*/
|
|
87
|
+
function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
|
|
88
|
+
let manifest;
|
|
89
|
+
try {
|
|
90
|
+
manifest = resolveNx();
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
// Only an absent package earns the "not installed" story. Any other
|
|
93
|
+
// resolver failure — say, an installed nx whose `exports` stopped exposing
|
|
94
|
+
// `./package.json` — is a different fact, and renaming it here would send
|
|
95
|
+
// the reader to install a package they already have.
|
|
96
|
+
if (/** @type {{code?: string}} */ (cause)?.code !== "MODULE_NOT_FOUND") throw cause;
|
|
97
|
+
throw new Error(
|
|
98
|
+
"archkeep: nx is not installed — `nx` is an optional peer dependency, needed only for " +
|
|
99
|
+
"`archkeep check`'s project-graph discovery (`nx graph`). Install it in this workspace, " +
|
|
100
|
+
"or run a command that does not need the graph.",
|
|
101
|
+
{ cause },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const { bin } = JSON.parse(readFileSync(manifest, "utf8"));
|
|
105
|
+
return join(dirname(manifest), typeof bin === "string" ? bin : bin.nx);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The Nx project graph for `workspaceRoot`, in the shape `evaluate()` consumes.
|
|
110
|
+
*
|
|
111
|
+
* `nx graph --file=<json>` emits `{ graph: { nodes, dependencies } }` and no
|
|
112
|
+
* `externalNodes`; the rule engine synthesises those from the analysis records
|
|
113
|
+
* instead, which is what makes `bannedExternalImports` reachable for crates and
|
|
114
|
+
* Go modules at all (`../rules/index.mjs` → `externalNodeFor`).
|
|
115
|
+
*
|
|
116
|
+
* The command also emits no `workspaceLayout` — measured, the same way — so
|
|
117
|
+
* this function reads `nx.json`'s own `workspaceLayout` key separately
|
|
118
|
+
* (`../options.mjs`'s `readWorkspaceLayout`) and merges it onto the graph
|
|
119
|
+
* before returning: without this, `../rules/index.mjs`'s
|
|
120
|
+
* `graph.workspaceLayout ?? DEFAULT_WORKSPACE_LAYOUT` fallback would apply the
|
|
121
|
+
* DEFAULT layout to every Nx workspace regardless of what `nx.json` declares,
|
|
122
|
+
* silencing `noRelativeOrAbsoluteImportsAcrossLibraries` on exactly the
|
|
123
|
+
* workspaces that set a non-default `appsDir`/`libsDir` (issue #31). The key
|
|
124
|
+
* is merged in only when something was declared and is complete
|
|
125
|
+
* (`requireCompleteWorkspaceLayout`) — absent when nothing was declared,
|
|
126
|
+
* exactly as `../providers/native/graph.mjs` already does for `archkeep.json`,
|
|
127
|
+
* which is what keeps `graph.workspaceLayout ?? DEFAULT_WORKSPACE_LAYOUT`
|
|
128
|
+
* working unchanged and keeps the two providers structurally identical here.
|
|
129
|
+
* A declared-but-incomplete layout throws rather than being silently dropped
|
|
130
|
+
* or silently completed — the same refusal
|
|
131
|
+
* `../providers/native/model.mjs`'s `workspaceLayoutViolations` already
|
|
132
|
+
* applies to `archkeep.json`'s identically-shaped field, so the two providers
|
|
133
|
+
* agree on the same declared object rather than one merging onto a default
|
|
134
|
+
* the other would have refused.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} workspaceRoot
|
|
137
|
+
* @param {{ run?: typeof runProcess, resolveNx?: () => string,
|
|
138
|
+
* readLayout?: typeof readWorkspaceLayout }} [io]
|
|
139
|
+
* Injectable spawn, injectable Nx resolution (see `nxCli`), and injectable
|
|
140
|
+
* `workspaceLayout` read (see `../options.mjs`).
|
|
141
|
+
* @returns {object} `{ nodes, dependencies }`, plus `workspaceLayout` when
|
|
142
|
+
* `nx.json` declares a complete one.
|
|
143
|
+
*/
|
|
144
|
+
export function readProjectGraph(
|
|
145
|
+
workspaceRoot,
|
|
146
|
+
{ run = runProcess, resolveNx, readLayout = readWorkspaceLayout } = {},
|
|
147
|
+
) {
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "archkeep-"));
|
|
149
|
+
const file = join(dir, "graph.json");
|
|
150
|
+
try {
|
|
151
|
+
run(process.execPath, [nxCli({ resolveNx }), "graph", `--file=${file}`], workspaceRoot);
|
|
152
|
+
const { graph } = JSON.parse(readFileSync(file, "utf8"));
|
|
153
|
+
if (!graph?.nodes) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`archkeep: \`nx graph\` produced no \`graph.nodes\` in ${file} — ` +
|
|
156
|
+
`nothing can be judged against a graph with no projects in it`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const workspaceLayout = requireCompleteWorkspaceLayout(readLayout(workspaceRoot));
|
|
160
|
+
return workspaceLayout === null ? graph : { ...graph, workspaceLayout };
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(dir, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The `ProjectModelProvider` object a future provider-selection seam holds —
|
|
168
|
+
* not consumed by anything yet (`cli.mjs` and `index.mjs` still import
|
|
169
|
+
* `readProjectGraph` directly, the same function this wraps), but exported now
|
|
170
|
+
* so a second provider has a real shape to match rather than a description of
|
|
171
|
+
* one.
|
|
172
|
+
*
|
|
173
|
+
* @type {ProjectModelProvider}
|
|
174
|
+
*/
|
|
175
|
+
export const nxProvider = {
|
|
176
|
+
name: "nx",
|
|
177
|
+
readProjectGraph,
|
|
178
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# `src/report/` — rendering violations and descriptive payloads
|
|
2
|
+
|
|
3
|
+
The formatters that turn command output into text or protocol payloads, one per
|
|
4
|
+
surface, sharing one input. Each is a pure function from records to output:
|
|
5
|
+
|
|
6
|
+
- `text.mjs` — the terminal report for `../../cli.mjs`'s `check`, in the
|
|
7
|
+
`file:line:column` shape an editor and a terminal both make clickable — which
|
|
8
|
+
is why the analysis record carries 1-based positions
|
|
9
|
+
(`../analysis/contract.md`);
|
|
10
|
+
- `sarif.mjs` — SARIF 2.1.0 for CI, so a failing job can be read without
|
|
11
|
+
scraping a human report, and so GitHub can annotate the diff. The failure it
|
|
12
|
+
guards against is a file GitHub silently rejects: the job stays green, no
|
|
13
|
+
annotation appears, and nothing says why. Nothing here validates against the
|
|
14
|
+
published schema — there is no schema validator in this workspace at all.
|
|
15
|
+
What `sarif.integration.test.mjs` pins instead is the subset of the 2.1.0
|
|
16
|
+
schema a rejected upload turns on, checked against the real message table:
|
|
17
|
+
`version`, `tool.driver.name`, a `ruleId` that resolves in the catalogue, a
|
|
18
|
+
non-empty `message.text`, and a repository-relative `uri` with a 1-based
|
|
19
|
+
`startLine`/`startColumn`.
|
|
20
|
+
- `graph-text.mjs` — the terminal report for `../../cli.mjs`'s `graph` command:
|
|
21
|
+
projects with their outgoing edges listed beneath each one, `(no
|
|
22
|
+
dependencies)` for projects with no edges, and a coverage claim above the
|
|
23
|
+
listing. Renders the same payload `json.mjs` wraps; decides nothing.
|
|
24
|
+
- `diff-text.mjs` — the terminal report for `../../cli.mjs`'s `diff` command:
|
|
25
|
+
baseline/head summaries, added/removed sections, and a change count. Empty
|
|
26
|
+
sections are omitted so "no changes" and "0 added, 0 removed" never look
|
|
27
|
+
identical. Renders the same payload `json.mjs` wraps; decides nothing.
|
|
28
|
+
- `impact-text.mjs` — the terminal report for `../../cli.mjs`'s `impact`
|
|
29
|
+
command: a coverage claim above the listing, the target project header, each
|
|
30
|
+
dependent on its own line, and a summary line with direct/transitive counts.
|
|
31
|
+
A project with no dependents states the 0 count explicitly — an empty
|
|
32
|
+
`dependents` list is a claim, not silence. Renders the same payload
|
|
33
|
+
`json.mjs` wraps; decides nothing.
|
|
34
|
+
- `explain-text.mjs` — the terminal report for `../../cli.mjs`'s `explain`
|
|
35
|
+
command: the `file:line:column` position unindented (clickable), then the
|
|
36
|
+
import specifier, source/target projects with tags, matched constraint rows,
|
|
37
|
+
and the verdict (allowed/VIOLATION/UNRESOLVABLE), each indented. Coverage
|
|
38
|
+
sits at the bottom, same shape as every other command. Renders the same
|
|
39
|
+
payload `json.mjs` wraps; decides nothing.
|
|
40
|
+
- `history-text.mjs` — the terminal report for `../../cli.mjs`'s `history`
|
|
41
|
+
command: the history directory, a capture line, each snapshot with its short
|
|
42
|
+
id, and each transition classified (architecture / policy / provider /
|
|
43
|
+
code drift / unchanged) with its changes and disclosure notes. Renders the
|
|
44
|
+
same payload `json.mjs` wraps; decides nothing.
|
|
45
|
+
- `evidence.mjs` — the decision builder, `buildDecision`: turns a command's
|
|
46
|
+
verdict counts into the `decision` the envelope optionally carries, enforcing
|
|
47
|
+
the five evidence invariants (`../governance/verdict.mjs` states them) in
|
|
48
|
+
code — `pass` requires complete coverage, `fail` requires a finding, `unknown`
|
|
49
|
+
requires a reason, `not_applicable` requires `notApplicableReason`, and a
|
|
50
|
+
could-not-determine run is never a `pass`. It decides nothing about whether a
|
|
51
|
+
finding IS one; it throws when the verdict and its evidence disagree.
|
|
52
|
+
|
|
53
|
+
`json.mjs` is not a formatter in that sense — it does not turn violations into
|
|
54
|
+
output. `jsonEnvelope` wraps whatever result object a command already computed
|
|
55
|
+
(`../../cli.mjs`'s `check` builds its own `result.violations`/`result.goWork`/
|
|
56
|
+
`result.tsconfigPaths`) in one versioned envelope every `--format json`
|
|
57
|
+
consumer shares, and enforces in code the three consistency rules
|
|
58
|
+
`docs/reference/json-output.md` documents in prose: `status: "ok"` never rides
|
|
59
|
+
incomplete coverage, `status` and `exitCode` never disagree, and
|
|
60
|
+
`coverage.complete` never disagrees with whether `coverage.notAnalyzed` is
|
|
61
|
+
empty. It throws rather than degrade on any of the three, because a mismatch
|
|
62
|
+
there is a bug in the command that built the envelope, not a fact about the
|
|
63
|
+
workspace being judged. It also refuses a `decision` whose `verdict` contradicts
|
|
64
|
+
the envelope's `status` — the same rule, at the evidence layer.
|
|
65
|
+
|
|
66
|
+
## Where the LSP conversion lives, and why not here
|
|
67
|
+
|
|
68
|
+
Not here. Language Server Protocol positions are 0-based and the analysis
|
|
69
|
+
record's are 1-based, and the subtraction is in `../lsp/diagnostics.mjs` beside
|
|
70
|
+
the rest of the protocol shaping — because a diagnostic is more than a
|
|
71
|
+
converted position, and splitting it across two directories would put half a
|
|
72
|
+
format in each. `../../AGENTS.md` records the same division: everything with a
|
|
73
|
+
decision in it lives under `src/lsp/`, and `lsp.mjs` holds only the wiring.
|
|
74
|
+
|
|
75
|
+
## The two formats say the same thing in different places
|
|
76
|
+
|
|
77
|
+
The terminal report puts the constraint row that fired on its own line; SARIF
|
|
78
|
+
appends it to `message.text`, because GitHub renders that field and nothing
|
|
79
|
+
else. Both carry the upstream `messageId` unchanged — SARIF as `ruleId`, text
|
|
80
|
+
as the first line — since that id, not the prose, is what makes a verdict
|
|
81
|
+
comparable to ESLint's.
|
|
82
|
+
|
|
83
|
+
## What must not land here
|
|
84
|
+
|
|
85
|
+
- **Any decision about whether something is a violation.** A formatter that
|
|
86
|
+
filters is a rule wearing a formatter's name, and it will disagree with the
|
|
87
|
+
rule engine the first time one of them changes.
|
|
88
|
+
- **A process exit code.** The exit-code table is `../../cli.mjs`'s contract
|
|
89
|
+
and is documented in its header.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `adr` command's text renderers — one function per surface the command
|
|
3
|
+
* prints, all pure so a test drives them without a registry on disk.
|
|
4
|
+
*
|
|
5
|
+
* The renderers state the registry and its binding completeness, and never
|
|
6
|
+
* invent a verdict the registry did not establish: an ADR with no bindings is
|
|
7
|
+
* named "not yet enforceable", a binding naming no known rule/fitness is named
|
|
8
|
+
* unknown, and an empty registry stays a sentence, never a table. The dump and
|
|
9
|
+
* the single-record report render one record's bindings through one function
|
|
10
|
+
* (`bindingsLine`), so the two faces cannot disagree about which of them is
|
|
11
|
+
* marked.
|
|
12
|
+
*/
|
|
13
|
+
import { ADR_DIR, ADR_STATUSES } from "../governance/adr-registry.mjs";
|
|
14
|
+
|
|
15
|
+
/** A status label for a record, in the text a human reads. */
|
|
16
|
+
function statusLabel(status) {
|
|
17
|
+
if (status === "accepted") return "accepted";
|
|
18
|
+
if (status === "superseded") return "superseded";
|
|
19
|
+
return "proposed";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `bindings:` line for one record — the ONE place either face decides how
|
|
24
|
+
* a binding is rendered.
|
|
25
|
+
*
|
|
26
|
+
* A binding names an id in the rule/fitness name space, and `../commands/adr.mjs`
|
|
27
|
+
* cannot verify that anything declares it (its header's "What it cannot
|
|
28
|
+
* assert" owns why). What it can do is say which ids the registry itself
|
|
29
|
+
* mentions, and mark the rest — so `(unknown)` is the reader's signal that a
|
|
30
|
+
* decision claims to bind something nothing here corroborates.
|
|
31
|
+
*
|
|
32
|
+
* This lived twice, and the second copy did not carry the marker at all:
|
|
33
|
+
* `formatAdrRecord` took no known set, so `adr <NNN-slug>` printed a dangling
|
|
34
|
+
* binding identically to an enforced one while `formatAdrDump` marked it on
|
|
35
|
+
* the same record, and the JSON face carried the fact to a machine reader that
|
|
36
|
+
* the terminal reader never saw. One function is what keeps the two faces from
|
|
37
|
+
* disagreeing again.
|
|
38
|
+
*
|
|
39
|
+
* @param {{bindings: string[]}} record
|
|
40
|
+
* @param {Set<string>} known The ids the registry's records mention.
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function bindingsLine(record, known) {
|
|
44
|
+
if (record.bindings.length === 0) {
|
|
45
|
+
return `bindings: (none — not yet enforceable)`;
|
|
46
|
+
}
|
|
47
|
+
const bound = record.bindings.map((binding) =>
|
|
48
|
+
known.has(binding) ? binding : `${binding} (unknown)`,
|
|
49
|
+
);
|
|
50
|
+
return `bindings: ${bound.join(", ")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renders the whole registry: one block per record, each naming its status,
|
|
55
|
+
* its supersession chain, and the rules/fitnesses it binds.
|
|
56
|
+
*
|
|
57
|
+
* @param {{records: object[], knownFitness?: Set<string>}} result
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function formatAdrDump({ records, knownFitness }) {
|
|
61
|
+
if (records.length === 0) {
|
|
62
|
+
return `no ADRs in ${ADR_DIR}/ — nothing is recorded, and nothing is enforceable through it`;
|
|
63
|
+
}
|
|
64
|
+
const known = knownFitness ?? new Set();
|
|
65
|
+
const blocks = records.map((record) => {
|
|
66
|
+
const header = `${record.id} (${statusLabel(record.status)})`;
|
|
67
|
+
const lines = [header, "-".repeat(header.length)];
|
|
68
|
+
if (record.supersedes.length > 0) {
|
|
69
|
+
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(bindingsLine(record, known));
|
|
72
|
+
lines.push(`status set: ${ADR_STATUSES.join(", ")}`);
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
});
|
|
75
|
+
return blocks.join("\n\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Renders a reverse lookup: which ADRs bind a given rule/fitness id. An empty
|
|
80
|
+
* answer is a sentence naming the unknown, never silence.
|
|
81
|
+
*
|
|
82
|
+
* @param {{fitnessId: string, adrIds: string[]}} result
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function formatAdrReverse({ fitnessId, adrIds }) {
|
|
86
|
+
if (adrIds.length === 0) {
|
|
87
|
+
return `no ADR in ${ADR_DIR}/ binds ${fitnessId} — it is not enforced by any recorded decision`;
|
|
88
|
+
}
|
|
89
|
+
return `${fitnessId} is bound by: ${adrIds.join(", ")}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Renders a requested ADR id that matches the `NNN-slug` pattern but names no
|
|
94
|
+
* file — the one case the command reads as unresolved, never as an empty
|
|
95
|
+
* reverse lookup.
|
|
96
|
+
*
|
|
97
|
+
* @param {{adrId: string}} result
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function formatAdrMissing({ adrId }) {
|
|
101
|
+
return (
|
|
102
|
+
`no ADR ${adrId} in ${ADR_DIR}/ — nothing is recorded under that id, and a ` +
|
|
103
|
+
`decisionRef naming it cannot resolve`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Renders one record's details — the `adr <NNN-slug>` face of the same record
|
|
109
|
+
* the dump above renders, and it must mark an unknown binding the same way:
|
|
110
|
+
* a reader who asked about one record sees strictly less than one who dumped
|
|
111
|
+
* the registry otherwise, and what they stop seeing is the marker.
|
|
112
|
+
*
|
|
113
|
+
* @param {{id: string, status: string, supersedes: string[], bindings: string[]}} record
|
|
114
|
+
* @param {Set<string>} [knownFitness] The ids the registry's records mention.
|
|
115
|
+
* Optional only so the parameter can be omitted where there is nothing to
|
|
116
|
+
* compare against; an absent set marks every binding rather than none,
|
|
117
|
+
* because "nothing corroborates this" is the honest answer when no set was
|
|
118
|
+
* supplied — never the quiet one.
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function formatAdrRecord(record, knownFitness) {
|
|
122
|
+
const header = `${record.id} (${statusLabel(record.status)})`;
|
|
123
|
+
const lines = [header, "-".repeat(header.length)];
|
|
124
|
+
if (record.supersedes.length > 0) {
|
|
125
|
+
lines.push(`supersedes: ${record.supersedes.join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
lines.push(bindingsLine(record, knownFitness ?? new Set()));
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `context` command: the architecture constraints
|
|
3
|
+
* that apply to a project.
|
|
4
|
+
*
|
|
5
|
+
* The project's tags are listed first, then each matching constraint row
|
|
6
|
+
* rendered the same way `./text.mjs`'s `formatConstraint` renders them — a
|
|
7
|
+
* reader who has seen a violation's constraint line in `check` output
|
|
8
|
+
* recognises the same shape here. When a constraint row carries `description`
|
|
9
|
+
* or `remediation`, those appear indented below the row.
|
|
10
|
+
*
|
|
11
|
+
* The project's current dependencies follow, each with a per-edge constraint
|
|
12
|
+
* verdict — so a developer (or an AI agent) sees which edges are allowed and
|
|
13
|
+
* which violate before writing a new import.
|
|
14
|
+
*
|
|
15
|
+
* A project with no tags states so explicitly — "no tags" — because a project
|
|
16
|
+
* with no tags and a renderer that printed nothing would be indistinguishable
|
|
17
|
+
* (`AGENTS.md`: "an empty result is a claim, not a shrug"). A project whose
|
|
18
|
+
* tags match no constraint row states that too: the `check` command would
|
|
19
|
+
* flag it as `projectWithoutTagsCannotHaveDependencies`, so a reader seeing
|
|
20
|
+
* "(no matching constraint rows)" knows the project is unconstrained rather
|
|
21
|
+
* than the command having failed to look.
|
|
22
|
+
*
|
|
23
|
+
* The coverage claim sits ABOVE the listing, not below it, so the reader
|
|
24
|
+
* knows whether the result is complete before reading any entry — the same
|
|
25
|
+
* reasoning as `./impact-text.mjs`.
|
|
26
|
+
*
|
|
27
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
28
|
+
* wearing a formatter's name (`../README.md`).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { formatConstraint } from "./text.mjs";
|
|
32
|
+
|
|
33
|
+
/** Two spaces of indent for detail lines. */
|
|
34
|
+
const DETAIL = " ";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The whole context report.
|
|
38
|
+
*
|
|
39
|
+
* @param {{projectContext: {project: string, tags: string[], constraints: object[],
|
|
40
|
+
* dependencies: {target: string, type: string, violations: object[]}[]},
|
|
41
|
+
* coverage: object, unresolvedDecisionRefs?: Set<string>}} input
|
|
42
|
+
* `unresolvedDecisionRefs` — `decisionRef` values a matched constraint row
|
|
43
|
+
* cites that do not resolve to any ADR, rule, or fitness record — is
|
|
44
|
+
* forwarded to `formatConstraint`, the same seam `check`'s report uses.
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function formatContextReport({ projectContext, coverage, unresolvedDecisionRefs }) {
|
|
48
|
+
const sections = [];
|
|
49
|
+
|
|
50
|
+
// Coverage claim goes FIRST — above the listing — so the reader knows
|
|
51
|
+
// whether the context is complete before reading any entry.
|
|
52
|
+
const inspected =
|
|
53
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
54
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
55
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
56
|
+
|
|
57
|
+
if (coverage.complete) {
|
|
58
|
+
sections.push(`✔ context complete (${inspected})`);
|
|
59
|
+
} else {
|
|
60
|
+
const notAnalyzedCount = coverage.notAnalyzed.length;
|
|
61
|
+
sections.push(
|
|
62
|
+
`✖ context incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
|
|
63
|
+
`could not be analyzed, so these constraints may be against an incomplete graph (${inspected})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Project name and tags.
|
|
68
|
+
const tagsText = projectContext.tags.length > 0 ? projectContext.tags.join(", ") : "none";
|
|
69
|
+
sections.push(`Project ${projectContext.project}`);
|
|
70
|
+
sections.push(`Tags ${tagsText}`);
|
|
71
|
+
|
|
72
|
+
// Constraint rows.
|
|
73
|
+
if (projectContext.constraints.length > 0) {
|
|
74
|
+
const count = projectContext.constraints.length;
|
|
75
|
+
sections.push(
|
|
76
|
+
`Constraints (${count} row${count === 1 ? "" : "s"} match${count === 1 ? "es" : ""}):`,
|
|
77
|
+
);
|
|
78
|
+
for (const constraint of projectContext.constraints) {
|
|
79
|
+
sections.push(`${DETAIL}${formatConstraint(constraint, unresolvedDecisionRefs)}`);
|
|
80
|
+
if (constraint.description) {
|
|
81
|
+
sections.push(`${DETAIL} description ${constraint.description}`);
|
|
82
|
+
}
|
|
83
|
+
if (constraint.remediation) {
|
|
84
|
+
sections.push(`${DETAIL} remediation ${constraint.remediation}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
sections.push(
|
|
89
|
+
"Constraints (no matching constraint rows — this project's tags match no depConstraints entry)",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Dependencies with per-edge verdicts.
|
|
94
|
+
if (projectContext.dependencies && projectContext.dependencies.length > 0) {
|
|
95
|
+
const count = projectContext.dependencies.length;
|
|
96
|
+
sections.push(`Dependencies (${count} edge${count === 1 ? "" : "s"}):`);
|
|
97
|
+
for (const dep of projectContext.dependencies) {
|
|
98
|
+
const verdict = dep.violations.length > 0 ? "VIOLATION" : "allowed";
|
|
99
|
+
sections.push(`${DETAIL}${dep.target} (${dep.type}) ${verdict}`);
|
|
100
|
+
for (const v of dep.violations) {
|
|
101
|
+
sections.push(`${DETAIL} ${v.messageId}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
sections.push("Dependencies (none)");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return sections.join("\n");
|
|
109
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `debt` command: the architecture-debt ledger as
|
|
3
|
+
* a table a reader can act on.
|
|
4
|
+
*
|
|
5
|
+
* Every section ends with a count, and the header states what the ledger is a
|
|
6
|
+
* claim about — how many entries, across how many snapshots, and whether the
|
|
7
|
+
* ages are real. `agings: false` is the debt-report way of saying "observed,
|
|
8
|
+
* not yet aged": ages are all 0 because the directory cannot establish time,
|
|
9
|
+
* and showing them as 0 while disclosing why keeps "this debt is exactly one
|
|
10
|
+
* snapshot old" from ever being read as "born yesterday".
|
|
11
|
+
*
|
|
12
|
+
* An empty ledger prints a "✔ no architecture debt" line — a claim about the
|
|
13
|
+
* whole comparison, never a bare zero. The aggregates are printed even when
|
|
14
|
+
* empty, so a reader can tell "no debt" from "the report forgot a section".
|
|
15
|
+
*
|
|
16
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
17
|
+
* wearing a formatter's name (`../README.md`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Neutralises control and terminal-escape sequences in a name or value before
|
|
22
|
+
* it is printed, so a crafted suppression path, project name or finding cannot
|
|
23
|
+
* inject escape sequences into a consumer's terminal (`SECURITY.md`). The same
|
|
24
|
+
* sanitation every other report renderer uses.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function sanitize(text) {
|
|
30
|
+
// eslint-disable-next-line no-control-regex
|
|
31
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
32
|
+
if (c === "\n") return "\\n";
|
|
33
|
+
if (c === "\t") return "\\t";
|
|
34
|
+
if (c === "\r") return "\\r";
|
|
35
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The whole debt report.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ledger: {dir: string, snapshots: number, agings: boolean,
|
|
43
|
+
* sampleTime: string, entries: {source: string, kind: string,
|
|
44
|
+
* severity: string, age: number, count: number, remediationHint: string}[],
|
|
45
|
+
* total: number, byKind: object, bySeverity: object},
|
|
46
|
+
* coverage: object}} input
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function formatDebtReport({ ledger, coverage }) {
|
|
50
|
+
const sections = [];
|
|
51
|
+
|
|
52
|
+
sections.push(`debt ${ledger.dir}`);
|
|
53
|
+
const word = ledger.total === 1 ? "entry" : "entries";
|
|
54
|
+
const ageWord = ledger.agings
|
|
55
|
+
? "a snapshot-relative ledger"
|
|
56
|
+
: "ages not yet established (fewer than two snapshots)";
|
|
57
|
+
sections.push(
|
|
58
|
+
`${ledger.total} ${word} across ${ledger.snapshots} snapshot${ledger.snapshots === 1 ? "" : "s"} — ${ageWord}`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const orderedKinds = [
|
|
62
|
+
["waiver", "waivers (accepted boundary violations)"],
|
|
63
|
+
["aspirational-gap", "aspirational gaps (optional allowed rows not built)"],
|
|
64
|
+
["drift", "drift findings"],
|
|
65
|
+
["unresolved", "unresolved intent"],
|
|
66
|
+
];
|
|
67
|
+
let sawAny = false;
|
|
68
|
+
for (const [kind, label] of orderedKinds) {
|
|
69
|
+
const items = ledger.entries.filter((e) => e.kind === kind);
|
|
70
|
+
if (items.length === 0) continue;
|
|
71
|
+
sawAny = true;
|
|
72
|
+
sections.push(`${items.length} ${label}:`);
|
|
73
|
+
for (const entry of items) {
|
|
74
|
+
const age = ledger.agings ? `age ${entry.age}` : "age not yet established";
|
|
75
|
+
sections.push(
|
|
76
|
+
` [${entry.kind}] ${entry.severity} ${sanitize(entry.source)} (${age}, count ${entry.count})`,
|
|
77
|
+
);
|
|
78
|
+
sections.push(` ${sanitize(entry.remediationHint)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!sawAny) {
|
|
83
|
+
sections.push(
|
|
84
|
+
"✔ no architecture debt — no waivers, aspirational gaps, drift or unresolved intent",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
sections.push(
|
|
89
|
+
`total ${ledger.total} ${word} · byKind: waiver ${ledger.byKind.waiver}, ` +
|
|
90
|
+
`aspirational-gap ${ledger.byKind["aspirational-gap"]}, drift ${ledger.byKind.drift}, ` +
|
|
91
|
+
`unresolved ${ledger.byKind.unresolved} · bySeverity: high ${ledger.bySeverity.high}, ` +
|
|
92
|
+
`medium ${ledger.bySeverity.medium}, low ${ledger.bySeverity.low}`,
|
|
93
|
+
);
|
|
94
|
+
sections.push(`sampled ${ledger.sampleTime}`);
|
|
95
|
+
|
|
96
|
+
// The coverage line states what the ledger is a claim about, the same
|
|
97
|
+
// disclosure every other report renderer makes (`graph-text.mjs`, etc.).
|
|
98
|
+
const inspected =
|
|
99
|
+
`${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
|
|
100
|
+
`${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
|
|
101
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
102
|
+
sections.push(coverage.complete ? `✔ complete (${inspected})` : `✖ ${inspected}`);
|
|
103
|
+
|
|
104
|
+
return sections.join("\n");
|
|
105
|
+
}
|