@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,161 @@
1
+ /**
2
+ * The envelope's field roster, as something a test can compare.
3
+ *
4
+ * `../../../../docs/reference/json-output.md` promises that every field name
5
+ * in it, and `schemaVersion` itself, are a public contract: no field is
6
+ * renamed, no field changes type, and a capability that does not fit the
7
+ * current shape ships as a new field rather than as a change to an existing
8
+ * one. Until this module existed, nothing in the tree went red when a field
9
+ * was renamed or dropped — the promise was prose, and `./json.mjs` enforces
10
+ * only the three consistency rules between `status`, `exitCode`, `coverage`
11
+ * and `decision`, never the roster itself.
12
+ *
13
+ * This module is the half that can be measured. It turns an envelope into a
14
+ * flat, sorted list of `path: type` entries, and `../../e2e/envelope-shape.e2e.mjs`
15
+ * compares that list — for every command the CLI declares — against the
16
+ * snapshot committed beside it.
17
+ *
18
+ * ## What the roster is, and what it is NOT
19
+ *
20
+ * It is a MEASURED shape, in the sense `scripts/differential-real-trees.mjs`
21
+ * uses for `expectViolations`: the answer a real run gives today, written
22
+ * down so that a change to it has to be made on purpose. It is not a second
23
+ * copy of the documentation's field tables, and it must not be maintained as
24
+ * one — a hand-written roster would be exactly the second statement of a rule
25
+ * `../../../../AGENTS.md` refuses, drifting from both the docs and the code.
26
+ * The snapshot is regenerated from a run and reviewed as a diff; the diff IS
27
+ * the review question ("is this rename allowed by the promise?").
28
+ *
29
+ * ## Why every node carries an entry, containers included
30
+ *
31
+ * A roster of leaves alone cannot see an emptied object: drop every key of
32
+ * `workspace` and the leaf list simply loses four rows, which reads the same
33
+ * as four fields having been renamed elsewhere. So each node emits its own
34
+ * `path: type` — `workspace: object` survives its children — and the type
35
+ * vocabulary keeps `null` apart from `object`, because `result.goWork` being
36
+ * `null` on a tree with no `go.work` and being an object on a tree with one
37
+ * is precisely the difference a consumer branches on.
38
+ *
39
+ * ## Arrays collapse, and that is the point
40
+ *
41
+ * Every element of an array folds onto one `path[]` prefix, so a roster does
42
+ * not grow with the number of violations a fixture happens to produce — the
43
+ * shape is what is under contract, not the count. A path that appears with
44
+ * two types across elements (an optional field present on one row and absent
45
+ * on another) yields both entries, which is how an optional field is
46
+ * recorded rather than hidden.
47
+ *
48
+ * The root itself gets no entry: `./json.mjs` cannot return anything but an
49
+ * object, so a row asserting it is one would be a row that can never move.
50
+ */
51
+
52
+ /**
53
+ * The type name a roster entry carries. Deliberately coarser than JavaScript's
54
+ * own vocabulary in one place and finer in two: `null` is its own name rather
55
+ * than `object`, and arrays are `array` rather than `object`, because those
56
+ * are the two distinctions a consumer's parser actually branches on.
57
+ *
58
+ * @param {unknown} value
59
+ * @returns {"null"|"array"|"object"|"string"|"number"|"boolean"}
60
+ */
61
+ function typeName(value) {
62
+ if (value === null) return "null";
63
+ if (Array.isArray(value)) return "array";
64
+ const type = typeof value;
65
+ if (type === "object" || type === "string" || type === "number" || type === "boolean") {
66
+ return type;
67
+ }
68
+ // `undefined`, a function, a symbol, a bigint: none can survive
69
+ // `JSON.stringify`, so a value of that type in an envelope is a bug in the
70
+ // command that built it rather than a shape to record. Naming it is the
71
+ // loud direction — recording it as some neighbouring type would put a row
72
+ // in the roster describing a field no consumer will ever receive.
73
+ throw new Error(
74
+ `archkeep: refusing to build an envelope roster over a ${type} — an envelope holds only ` +
75
+ `JSON values, so a ${type} in it is a bug in the command that built the envelope.`,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Every node of `envelope`, as sorted `"<path>: <type>"` entries.
81
+ *
82
+ * Paths are dotted, with `[]` marking a step through an array: a violation's
83
+ * message id is `result.violations[].messageId`. Duplicate entries — the same
84
+ * path with the same type on many array elements — collapse to one.
85
+ *
86
+ * @param {object} envelope The envelope `./json.mjs` built.
87
+ * @returns {string[]} Sorted, unique `path: type` entries.
88
+ * @throws {Error} when the envelope is not an object, or holds a value no
89
+ * JSON document can carry.
90
+ */
91
+ export function envelopeFieldPaths(envelope) {
92
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
93
+ throw new Error(
94
+ `archkeep: refusing to build an envelope roster over ${envelope === null ? "null" : `a ${Array.isArray(envelope) ? "array" : typeof envelope}`} ` +
95
+ `— an envelope is the object ./json.mjs returns.`,
96
+ );
97
+ }
98
+ /** @type {Map<string, Set<string>>} */
99
+ const byPath = new Map();
100
+ walk(envelope, "", byPath);
101
+ // Sorted by path first and type second, rather than by the rendered string:
102
+ // `"tool.name: string"` sorts BEFORE `"tool: object"` under a plain string
103
+ // sort (`.` is below `:`), which would print every child above its own
104
+ // parent and make the snapshot's diffs harder to read than they need to be.
105
+ return [...byPath.keys()]
106
+ .sort()
107
+ .flatMap((path) => [...(byPath.get(path) ?? [])].sort().map((type) => `${path}: ${type}`));
108
+ }
109
+
110
+ /**
111
+ * Records `value` at `path` and descends. `path` is `""` only for the root,
112
+ * whose own entry is deliberately not recorded (see this module's header).
113
+ *
114
+ * @param {unknown} value
115
+ * @param {string} path
116
+ * @param {Map<string, Set<string>>} byPath
117
+ */
118
+ function walk(value, path, byPath) {
119
+ if (path !== "") {
120
+ const types = byPath.get(path) ?? new Set();
121
+ types.add(typeName(value));
122
+ byPath.set(path, types);
123
+ }
124
+ if (value === null) return;
125
+ if (Array.isArray(value)) {
126
+ for (const element of value) walk(element, `${path}[]`, byPath);
127
+ return;
128
+ }
129
+ if (typeof value !== "object") return;
130
+ for (const key of Object.keys(value)) {
131
+ walk(
132
+ /** @type {Record<string, unknown>} */ (value)[key],
133
+ path === "" ? key : `${path}.${key}`,
134
+ byPath,
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The two directions a roster can move, named separately because they are not
141
+ * the same review question.
142
+ *
143
+ * `added` is the additive direction the promise allows — a new field, which
144
+ * still has to be recorded so that "additive" is a claim someone made rather
145
+ * than a diff nobody read. `removed` is the direction the promise forbids: a
146
+ * field that was in the roster and is not in the run either was renamed,
147
+ * dropped, or changed type, and every one of those breaks a consumer that
148
+ * parses this output today.
149
+ *
150
+ * @param {string[]} recorded The snapshot's entries for this command.
151
+ * @param {string[]} observed `envelopeFieldPaths` over a real run.
152
+ * @returns {{added: string[], removed: string[]}}
153
+ */
154
+ export function compareFieldPaths(recorded, observed) {
155
+ const recordedSet = new Set(recorded);
156
+ const observedSet = new Set(observed);
157
+ return {
158
+ added: observed.filter((entry) => !recordedSet.has(entry)),
159
+ removed: recorded.filter((entry) => !observedSet.has(entry)),
160
+ };
161
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The decision builder: turns a command's verdict counts into the `decision`
3
+ * the envelope optionally carries, enforcing the five evidence invariants
4
+ * (`../governance/verdict.mjs` states them) in code rather than leaving them
5
+ * to a docs page a later command author might not read.
6
+ *
7
+ * This module decides nothing about whether a finding IS one — the command
8
+ * that built the envelope owns that. What it decides is whether the verdict
9
+ * and its evidence AGREE, and it throws when they do not, the same posture
10
+ * `jsonEnvelope` takes for the three consistency rules it enforces: a
11
+ * mismatch here is a bug in the command, not a fact about the workspace.
12
+ *
13
+ * The shape it produces:
14
+ *
15
+ * {
16
+ * verdict: "pass" | "fail" | "unknown" | "not_applicable",
17
+ * reason?: string, // always present for unknown
18
+ * notApplicableReason?: string, // always present for not_applicable
19
+ * sampleTime?: string // opt-in, never on a deterministic envelope
20
+ * }
21
+ *
22
+ * A caller may pass `reason` for `unknown` — it names WHICH could-not-look
23
+ * condition fired (coverage incomplete, an unresolved intent boundary, a
24
+ * thrown analysis). Without it, `buildDecision` states the generic one. The
25
+ * reason field itself is always present on an `unknown` decision (I3).
26
+ *
27
+ * ## Determinism is the default
28
+ *
29
+ * The envelope this decision rides on is byte-deterministic
30
+ * (`docs/reference/json-output.md`: no timestamp, no random identifier). So
31
+ * `sampleTime` is OPT-IN by construction: a command passes it explicitly when
32
+ * it is an age/count capability (waivers, debt, health — the features
33
+ * `../governance/clock.mjs` serves), and a command whose verdict must stay
34
+ * reproducible over an unchanged tree emits a decision with no time at all.
35
+ * That is how the determinism↔time tension is resolved — the clock is
36
+ * injectable (a test drives the same code with a fixed time), never asserted
37
+ * from the wall clock.
38
+ *
39
+ * ## The invariants, executable
40
+ *
41
+ * I1. `pass` requires complete coverage. A run that could not fully read the
42
+ * tree can never pass — the same refusal `jsonEnvelope` makes for
43
+ * `status: "ok"` over incomplete coverage, at the verdict layer.
44
+ * I2. `fail` requires at least one finding. A failing verdict that names no
45
+ * finding leaves the reader guessing what failed.
46
+ * I3. `unknown` requires a reason. `unknown` is a claim that something could
47
+ * not be determined, and the reader has to be able to tell what.
48
+ * I4. `not_applicable` requires `notApplicableReason`. "Did not apply" and
49
+ * "did not run" are indistinguishable without it.
50
+ * I5. The cardinal rule: a failed analysis or an unresolved question must
51
+ * emit `unknown`, NEVER `pass` — enforced here by I1's first check
52
+ * (pass + not-complete throws) and by every caller choosing `unknown`
53
+ * wherever the run did not reach a verdict.
54
+ *
55
+ * `not_applicable` has no envelope status, so `buildDecision` reaches it only
56
+ * through an explicit `verdict` — the route a Fitness or Waiver capability
57
+ * (a later governance wave) takes. Engine behavior today never passes it:
58
+ * `jsonEnvelope` refuses a `decision.verdict` that contradicts the envelope's
59
+ * `status`, and no status maps to `not_applicable`, so the state is locked
60
+ * out of every envelope this release builds.
61
+ */
62
+ import { verdictForStatus } from "../governance/verdict.mjs";
63
+
64
+ /**
65
+ * Builds the `decision` a verdict's counts produce.
66
+ *
67
+ * @param {{
68
+ * verdict?: "pass"|"fail"|"unknown"|"not_applicable",
69
+ * status?: "ok"|"findings"|"no-verdict",
70
+ * coverageComplete: boolean,
71
+ * findings: number,
72
+ * reason?: string|null,
73
+ * notApplicableReason?: string|null,
74
+ * sampleTime?: string
75
+ * }} run
76
+ * @returns {{verdict: string, reason?: string, notApplicableReason?: string,
77
+ * sampleTime?: string}}
78
+ * @throws {Error} on any invariant violation (I1–I4).
79
+ */
80
+ export function buildDecision(run) {
81
+ if (run.verdict === undefined && run.status === undefined) {
82
+ // No status, no explicit verdict — a builder called with neither is a
83
+ // programming error, not a fact about the workspace.
84
+ throw new Error("archkeep: buildDecision needs either a status or an explicit verdict");
85
+ }
86
+ const verdict = run.verdict ?? verdictForStatus(run.status);
87
+ if (
88
+ run.verdict !== undefined &&
89
+ run.status !== undefined &&
90
+ run.verdict !== verdictForStatus(run.status)
91
+ ) {
92
+ throw new Error(
93
+ `archkeep: refusing to build a decision where verdict "${run.verdict}" contradicts status ` +
94
+ `"${run.status}" — status implies ${verdictForStatus(run.status)}, and a decision that ` +
95
+ `disagrees with its own status would make one of the two a lie. ` +
96
+ `This is a bug in the command that built the decision.`,
97
+ );
98
+ }
99
+
100
+ if (verdict === "pass") {
101
+ if (run.coverageComplete !== true) {
102
+ throw new Error(
103
+ `archkeep: refusing to emit a "pass" decision over incomplete coverage ` +
104
+ `(coverage.complete: ${run.coverageComplete}) — a run that could not fully read the ` +
105
+ `tree can never pass. This is a bug in the command that built the decision.`,
106
+ );
107
+ }
108
+ if (run.findings > 0) {
109
+ throw new Error(
110
+ `archkeep: refusing to emit a "pass" decision with ${run.findings} finding(s) — ` +
111
+ `"pass" and "fail" cannot both be true of the same run. This is a bug in the command.`,
112
+ );
113
+ }
114
+ return withSampleTime({ verdict }, run.sampleTime);
115
+ }
116
+
117
+ if (verdict === "fail") {
118
+ if (run.findings < 1) {
119
+ throw new Error(
120
+ `archkeep: refusing to emit a "fail" decision with no findings — a failing verdict ` +
121
+ `must name what failed. This is a bug in the command that built the decision.`,
122
+ );
123
+ }
124
+ return withSampleTime({ verdict }, run.sampleTime);
125
+ }
126
+
127
+ if (verdict === "unknown") {
128
+ const reason =
129
+ run.reason ??
130
+ (run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
131
+ return withSampleTime({ verdict, reason }, run.sampleTime);
132
+ }
133
+
134
+ // verdict === "not_applicable" (I4).
135
+ if (!run.notApplicableReason) {
136
+ throw new Error(
137
+ `archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
138
+ `"did not apply" and "did not run" must never be indistinguishable. ` +
139
+ `This is a bug in the command that built the decision.`,
140
+ );
141
+ }
142
+ return withSampleTime({ verdict, notApplicableReason: run.notApplicableReason }, run.sampleTime);
143
+ }
144
+
145
+ /**
146
+ * Adds `sampleTime` to the decision only when the caller opted into time —
147
+ * the determinism rule in this module's header. Absent `sampleTime`, the
148
+ * decision object carries exactly the invariant-bearing fields and nothing
149
+ * more.
150
+ *
151
+ * @param {object} decision
152
+ * @param {string|undefined} sampleTime
153
+ * @returns {object}
154
+ */
155
+ function withSampleTime(decision, sampleTime) {
156
+ return sampleTime === undefined ? decision : { ...decision, sampleTime };
157
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The terminal report for the `explain` command: one import site's judgment,
3
+ * explained.
4
+ *
5
+ * The first line is `file:line:column`, unindented and with no prefix — the
6
+ * same shape the terminal and the editor both make clickable, and the same
7
+ * shape `./text.mjs` uses for violations. Everything after it is indented,
8
+ * so the position line stands alone.
9
+ *
10
+ * Seven things are printed, each with a reader in mind:
11
+ *
12
+ * - the import specifier and its kind (what was written)
13
+ * - the source project and its tags (who wrote it)
14
+ * - the target project and its tags (where it reaches)
15
+ * - the constraint row(s) that matched (which rule applied)
16
+ * - the verdict (violation or allowed)
17
+ * - the message, when there is one (what the verdict means in prose)
18
+ * - coverage information (whether this explanation is complete)
19
+ *
20
+ * This module decides nothing. A formatter that filtered would be a rule
21
+ * wearing a formatter's name (`../README.md`).
22
+ */
23
+
24
+ /** Two spaces of indent for detail lines. */
25
+ const DETAIL = " ";
26
+
27
+ /**
28
+ * Tags rendered as a bracketed list: `[layer:domain, scope:billing]`.
29
+ * An empty list renders as `[]`.
30
+ *
31
+ * @param {string[]} tags
32
+ * @returns {string}
33
+ */
34
+ function formatTags(tags) {
35
+ return `[${tags.join(", ")}]`;
36
+ }
37
+
38
+ /**
39
+ * A project name with its tags: `billing-core [layer:domain, scope:billing]`.
40
+ * A null project renders as `(unresolved)`.
41
+ *
42
+ * @param {string|null} name
43
+ * @param {string[]} tags
44
+ * @returns {string}
45
+ */
46
+ function formatProject(name, tags) {
47
+ if (name === null) return "(unresolved)";
48
+ return `${name} ${formatTags(tags)}`;
49
+ }
50
+
51
+ /**
52
+ * One constraint row rendered from the row's own keys — the same function
53
+ * `./text.mjs`'s `formatConstraint` uses, copied here because that function
54
+ * is not exported (it does not need to be; the two are parallel renderers).
55
+ *
56
+ * @param {object|null} constraint A `depConstraints` row, or `null`.
57
+ * @returns {string}
58
+ */
59
+ function formatConstraint(constraint) {
60
+ if (!constraint) {
61
+ return "not driven by a depConstraints row — this check fires before the table is read";
62
+ }
63
+ const source =
64
+ "allSourceTags" in constraint
65
+ ? `allSourceTags [${constraint.allSourceTags.join(", ")}]`
66
+ : `sourceTag ${constraint.sourceTag}`;
67
+ const rest = Object.entries(constraint)
68
+ .filter(([key]) => key !== "sourceTag" && key !== "allSourceTags")
69
+ .map(([key, value]) => `${key} [${(Array.isArray(value) ? value : [value]).join(", ")}]`);
70
+ return [source, ...rest].join(" → ");
71
+ }
72
+
73
+ /**
74
+ * One matched constraint row rendered as a readable line.
75
+ *
76
+ * @param {object} constraint A `depConstraints` row.
77
+ * @returns {string}
78
+ */
79
+ function formatMatchedConstraint(constraint) {
80
+ return formatConstraint(constraint);
81
+ }
82
+
83
+ /**
84
+ * The whole explain report.
85
+ *
86
+ * @param {{explanation: object, coverage: object}} input
87
+ * @returns {string}
88
+ */
89
+ export function formatExplainReport({ explanation, coverage }) {
90
+ const { site } = explanation;
91
+ const sections = [];
92
+
93
+ // The position line — same shape as a violation entry.
94
+ sections.push(`${site.file}:${site.line}:${site.column}`);
95
+
96
+ if (explanation.unresolvable) {
97
+ sections.push(`${DETAIL}unresolvable ${explanation.reason}`);
98
+ sections.push(
99
+ `${DETAIL}verdict UNRESOLVABLE — this import site could not be resolved statically, ` +
100
+ `so no judgment was reached`,
101
+ );
102
+ } else {
103
+ const imp = explanation.import;
104
+ sections.push(`${DETAIL}import ${JSON.stringify(imp.specifier)} (${imp.kind})`);
105
+ sections.push(
106
+ `${DETAIL}source ${formatProject(explanation.sourceProject, explanation.sourceTags)}`,
107
+ );
108
+ sections.push(
109
+ `${DETAIL}target ${formatProject(explanation.targetProject, explanation.targetTags)}`,
110
+ );
111
+
112
+ if (explanation.matchedConstraints.length > 0) {
113
+ for (const constraint of explanation.matchedConstraints) {
114
+ sections.push(`${DETAIL}constraint ${formatMatchedConstraint(constraint)}`);
115
+ }
116
+ } else {
117
+ sections.push(
118
+ `${DETAIL}constraint (none — the source project matches no depConstraints row)`,
119
+ );
120
+ }
121
+
122
+ if (explanation.violations && explanation.violations.length > 0) {
123
+ for (const v of explanation.violations) {
124
+ sections.push(`${DETAIL}verdict VIOLATION — ${v.messageId}`);
125
+ // The rendered message — same indent as the violation report.
126
+ const message = v.message
127
+ .split("\n")
128
+ .map((line) => (line === "" ? "" : `${DETAIL} ${line}`))
129
+ .join("\n");
130
+ sections.push(message);
131
+ if (v.constraint?.description) {
132
+ sections.push(`${DETAIL}rule ${v.constraint.description}`);
133
+ }
134
+ if (v.constraint?.remediation) {
135
+ sections.push(`${DETAIL}remediation ${v.constraint.remediation}`);
136
+ }
137
+ }
138
+ } else {
139
+ sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
140
+ }
141
+ }
142
+
143
+ // Coverage — same shape as every other command's footer.
144
+ const inspected =
145
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
146
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
147
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
148
+
149
+ if (coverage.complete) {
150
+ sections.push(`✔ coverage complete (${inspected})`);
151
+ } else {
152
+ sections.push(
153
+ `✖ coverage incomplete — ${coverage.notAnalyzed.length} file${coverage.notAnalyzed.length === 1 ? "" : "s"} ` +
154
+ `could not be analyzed (${inspected})`,
155
+ );
156
+ }
157
+
158
+ return sections.join("\n");
159
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The terminal report for the `graph` command: the project graph as readable
3
+ * text.
4
+ *
5
+ * Each project lists its outgoing edges beneath it — the reading order an
6
+ * architect uses ("what does this project reach?"). A project with no
7
+ * dependencies renders `(no dependencies)` rather than an omitted section,
8
+ * because a project with no edges and a project the renderer forgot look
9
+ * identical otherwise — the same reasoning as `./text.mjs`'s
10
+ * `formatGoWork` clean-result line.
11
+ *
12
+ * The coverage claim sits ABOVE the listing, not below it, so the reader knows
13
+ * whether the snapshot is complete before reading any entry — an incomplete
14
+ * snapshot printed in full would have the "this may under-represent" warning
15
+ * buried at the bottom.
16
+ *
17
+ * This module decides nothing. A formatter that filtered would be a rule
18
+ * wearing a formatter's name (`../README.md`).
19
+ */
20
+
21
+ /**
22
+ * One project as a line: name, root, type, and tags.
23
+ *
24
+ * @param {{name: string, root: string, type?: string, tags: string[]}} project
25
+ * @returns {string}
26
+ */
27
+ function formatProject(project) {
28
+ const type = project.type ? ` ${project.type}` : "";
29
+ const tags = project.tags.length > 0 ? ` [${project.tags.join(", ")}]` : "";
30
+ return ` ${project.name} ${project.root}${type}${tags}`;
31
+ }
32
+
33
+ /**
34
+ * One edge as a line: source -> target (type).
35
+ *
36
+ * @param {{source: string, target: string, type: string}} edge
37
+ * @returns {string}
38
+ */
39
+ function formatEdge(edge) {
40
+ return ` → ${edge.target} (${edge.type})`;
41
+ }
42
+
43
+ /**
44
+ * The whole graph report.
45
+ *
46
+ * @param {{projects: object[], dependencies: object[], workspaceLayout: object,
47
+ * workspaceLayoutSource: string, coverage: object}} input
48
+ * @returns {string}
49
+ */
50
+ export function formatGraphReport({
51
+ projects,
52
+ dependencies,
53
+ workspaceLayout,
54
+ workspaceLayoutSource,
55
+ coverage,
56
+ }) {
57
+ const sections = [];
58
+
59
+ // Coverage claim goes FIRST — above the listing — so the reader knows
60
+ // whether the snapshot is complete before reading any entry.
61
+ const inspected =
62
+ `${coverage.imports} import${coverage.imports === 1 ? "" : "s"} in ` +
63
+ `${coverage.analyzedFiles} file${coverage.analyzedFiles === 1 ? "" : "s"} across ` +
64
+ `${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
65
+
66
+ if (coverage.complete) {
67
+ sections.push(`✔ graph snapshot complete (${inspected})`);
68
+ } else {
69
+ const notAnalyzedCount = coverage.notAnalyzed.length;
70
+ sections.push(
71
+ `✖ graph snapshot incomplete — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
72
+ `could not be analyzed, so this snapshot may under-represent the architecture (${inspected})`,
73
+ );
74
+ }
75
+
76
+ // Layout line
77
+ const layoutSource = workspaceLayoutSource === "declared" ? "declared in workspace" : "default";
78
+ sections.push(`layout ${workspaceLayout.appsDir}/${workspaceLayout.libsDir} (${layoutSource})`);
79
+
80
+ // Project count header
81
+ const projectWord = projects.length === 1 ? "project" : "projects";
82
+ sections.push(`${projects.length} ${projectWord}`);
83
+
84
+ // Build per-project edge map for the dependency sub-lists
85
+ const edgesBySource = new Map();
86
+ for (const edge of dependencies) {
87
+ let list = edgesBySource.get(edge.source);
88
+ if (!list) {
89
+ list = [];
90
+ edgesBySource.set(edge.source, list);
91
+ }
92
+ list.push(edge);
93
+ }
94
+
95
+ // Each project lists its outgoing edges beneath it
96
+ for (const project of projects) {
97
+ sections.push(formatProject(project));
98
+ const edges = edgesBySource.get(project.name);
99
+ if (edges && edges.length > 0) {
100
+ for (const edge of edges) {
101
+ sections.push(formatEdge(edge));
102
+ }
103
+ } else {
104
+ // A project with no dependencies still gets a line, so the reader can
105
+ // tell the project exists from the report alone — the same reasoning
106
+ // as `./text.mjs`'s `formatGoWork` clean-result line.
107
+ sections.push(" (no dependencies)");
108
+ }
109
+ }
110
+
111
+ // Flat edge count for summary
112
+ const edgeWord = dependencies.length === 1 ? "edge" : "edges";
113
+ sections.push(`${dependencies.length} ${edgeWord}`);
114
+
115
+ return sections.join("\n");
116
+ }