@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.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. 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
+ }