@ecoma-io/archkeep 0.14.0 → 0.16.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 (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
@@ -232,3 +232,61 @@ export function buildNativeGraph({
232
232
  ...(exemptedFiles && exemptedFiles.length > 0 ? { exemptedFiles } : {}),
233
233
  };
234
234
  }
235
+
236
+ /**
237
+ * Folds manifest-resolver records — `{source, target, sourceFile, type}` from
238
+ * `../../graph/create-dependencies.mjs`'s `resolveDeclaredManifestEdges` —
239
+ * into an already-built graph's `dependencies`, in place. The callers are the
240
+ * graph assemblies that have no plugin host to draw these edges for them:
241
+ * this module's own `buildGraph` (the CLI's native branch and this package's
242
+ * language-server index), the Moon branch of both, and the language server's
243
+ * Nx-shaped index. On the CLI's Nx branch the polyglot plugin registers into
244
+ * Nx's own graph computation instead — `resolvePolyglotDependencies` there,
245
+ * these same resolvers — so that face arrives with the edges already drawn.
246
+ *
247
+ * Deduplication is by `(source, target, type)`, the same key `buildDependencies`
248
+ * dedupes import sites by and the same key the JSON envelope flattens on, so a
249
+ * dependency witnessed by BOTH tracks — a written `using` and the
250
+ * `<ProjectReference>` for it, a Kotlin import and the Gradle `project(":x")`
251
+ * — carries one record, exactly as one witnessed by two imports does. The
252
+ * resolvers iterate only the workspace's own projects and resolve every target
253
+ * against that same project list, so source and target are nodes of the graph
254
+ * this fold serves by construction; no membership check is taken here, because
255
+ * one that skipped would be the silent direction — an edge dropped for not
256
+ * being a node is a finding unreported, where a mismatch between the merged
257
+ * records and the graph's nodes is a caller bug that should surface.
258
+ *
259
+ * @param {{nodes: Record<string, object>, dependencies?: Record<string, {source: string, target: string, type: string}[]>}} graph
260
+ * Mutated in place.
261
+ * @param {{source: string, target: string, sourceFile?: string, type: string}[]} records
262
+ * @returns {{nodes: Record<string, object>, dependencies: Record<string, {source: string, target: string, type: string}[]>}} The same graph.
263
+ */
264
+ export function mergeDeclaredEdges(graph, records) {
265
+ // Null-prototype for the same reason `buildDependencies` above uses one:
266
+ // every key is a project name this package does not control.
267
+ const dependencies = graph.dependencies ?? Object.create(null);
268
+ graph.dependencies = dependencies;
269
+ const seen = new Set();
270
+ for (const list of Object.values(dependencies)) {
271
+ for (const edge of list ?? []) {
272
+ seen.add(JSON.stringify([edge.source, edge.target, edge.type]));
273
+ }
274
+ }
275
+ for (const record of records) {
276
+ const key = JSON.stringify([record.source, record.target, record.type]);
277
+ if (seen.has(key)) continue;
278
+ seen.add(key);
279
+ (dependencies[record.source] ??= []).push({
280
+ source: record.source,
281
+ target: record.target,
282
+ type: record.type,
283
+ });
284
+ }
285
+ // The cast is the post-condition the signature promises: the fold above
286
+ // guarantees `dependencies` exists on the graph it returns, but the
287
+ // parameter's declared type — which admits a graph arriving without one —
288
+ // is what `graph` carries here.
289
+ return /** @type {{nodes: Record<string, object>, dependencies: Record<string, {source: string, target: string, type: string}[]>}} */ (
290
+ graph
291
+ );
292
+ }
@@ -195,10 +195,66 @@ export const DEFAULT_MANIFEST_NAMES = Object.freeze([
195
195
  "go.mod",
196
196
  "Cargo.toml",
197
197
  "pyproject.toml",
198
+ // Maven: every tracked root pom.xml anchors a project (ADR 0005). Identity
199
+ // is `(groupId, artifactId)` read by `../../analysis/jvm/maven.mjs`; the
200
+ // name follows the same precedence as every other inferred manifest, so
201
+ // pom-discovered projects land on their directory basename unless a
202
+ // declared row names them.
203
+ "pom.xml",
204
+ // Gradle: settings.gradle / settings.gradle.kts anchors a Gradle build
205
+ // (ADR 0005 Decision 2). Identity is the root project name and included
206
+ // projects from the settings file; edges are read from build.gradle /
207
+ // build.gradle.kts by `../../analysis/jvm/gradle.mjs`. The settings file
208
+ // is the discovery manifest — it defines the reactor structure — while
209
+ // the build files hold the dependency declarations.
210
+ "settings.gradle",
211
+ "settings.gradle.kts",
212
+ // .NET/C#: every tracked root .csproj anchors a project (ADR 0006). Identity
213
+ // is the project name read by `../../analysis/dotnet/csproj.mjs`; the
214
+ // name follows the same precedence as every other inferred manifest, so
215
+ // csproj-discovered projects land on their directory basename unless a
216
+ // declared row names them.
217
+ "*.csproj",
198
218
  ]);
199
219
 
200
220
  const PROJECT_TYPES = ["app", "lib", "e2e"];
201
221
 
222
+ /**
223
+ * The deliberate default for `projects.infer.exclude` — the anchor-exclusion
224
+ * half of the phantom-project policy (issue #371): a tracked manifest inside a
225
+ * directory that is documentation or test data about the workspace, rather
226
+ * than a part of it, never anchors an inferred project, because inference over
227
+ * it would judge a phantom as real. `docs/`, `fixtures/` and `__fixtures__/`
228
+ * as whole path segments are the complete set: those three names mean "data
229
+ * about the tree" in every convention this package has measured, and a name
230
+ * like `examples/` was deliberately left OFF it — example projects are
231
+ * commonly real, built, governed code, so excluding them by name would be the
232
+ * same over-broad-by-name error the `obj`/`bin` half of the same issue
233
+ * repudiated (`./discover.mjs`'s `isDotnetGeneratedOutput`).
234
+ *
235
+ * Two facts make this exclusion safe rather than a silent hole:
236
+ *
237
+ * - `projects.declared` is exempt — a workspace with a real project under one
238
+ * of these paths declares it, and declaration is the authoritative channel
239
+ * inference never touches.
240
+ * - A dropped anchor's analyzable files do not vanish silently: they surface
241
+ * through `./coverage.mjs`'s unclaimed-file judgment as whole-file failures
242
+ * until the workspace either declares the project or records a reasoned
243
+ * `coverage.exempt` row — which is the loud, explicit opt-in for "this
244
+ * directory is fixture data".
245
+ *
246
+ * An explicit `exclude` list REPLACES this default (the `tsc` convention for
247
+ * the same field): a workspace that names its own list takes over the whole
248
+ * decision, `exclude: []` included — that spelling is the documented opt-out.
249
+ *
250
+ * @see DEFAULT_MANIFEST_NAMES
251
+ */
252
+ export const DEFAULT_INFER_EXCLUDE = Object.freeze([
253
+ "**/docs/**",
254
+ "**/fixtures/**",
255
+ "**/__fixtures__/**",
256
+ ]);
257
+
202
258
  /** @type {(value: unknown) => value is Record<string, unknown>} */
203
259
  const isPlainObject = (value) =>
204
260
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -702,7 +758,9 @@ export function normalizeNativeModel(raw) {
702
758
  : {
703
759
  manifests: rawInfer.manifests ?? DEFAULT_MANIFEST_NAMES,
704
760
  include: rawInfer.include ?? ["**"],
705
- exclude: rawInfer.exclude ?? [],
761
+ // Replaces, never merges: an explicit list takes over the whole
762
+ // decision — `DEFAULT_INFER_EXCLUDE`'s doc comment owns the why.
763
+ exclude: rawInfer.exclude ?? DEFAULT_INFER_EXCLUDE,
706
764
  },
707
765
  },
708
766
  projectRules: /** @type {unknown[]} */ (raw.projectRules ?? []).map((row) => {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The terminal report for the `change` command: a declared change-intent
3
+ * contract against the architectural delta actually observed
4
+ * (`../commands/change.mjs`).
5
+ *
6
+ * The report is a review document, so it renders every axis separately and in
7
+ * full: what was declared, what matched, what appeared without a declaration,
8
+ * which declared changes never happened, how each declared constraint judged,
9
+ * and — informational, never this command's verdict — how many live boundary
10
+ * violations the tree currently carries under the current law, with `check`
11
+ * named as the authority on that axis. A section renders only when it has
12
+ * content; the closing line always states what was compared and with what
13
+ * outcome, so an empty reconciliation is a verifiable claim rather than
14
+ * silence (`../../../../AGENTS.md`).
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
+ /** One expected-fact row as report lines. */
21
+ function factLines(entry) {
22
+ switch (entry.kind) {
23
+ case "project-added":
24
+ return [` + project ${entry.project}`];
25
+ case "project-removed":
26
+ return [` - project ${entry.project}`];
27
+ case "edge-added":
28
+ return [
29
+ ` + edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
30
+ ];
31
+ case "edge-removed":
32
+ return [
33
+ ` - edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
34
+ ];
35
+ case "project-changed": {
36
+ const lines = [` ! project ${entry.project} changed:`];
37
+ for (const change of entry.changes ?? []) {
38
+ lines.push(
39
+ ` ${change.field}: ${JSON.stringify(change.baseline)} -> ${JSON.stringify(change.head)}`,
40
+ );
41
+ }
42
+ return lines;
43
+ }
44
+ default:
45
+ return [` ? ${entry.kind} ${entry.project ?? `${entry.from} -> ${entry.to}`}`];
46
+ }
47
+ }
48
+
49
+ /** One constraint verdict row as its report lines. */
50
+ function constraintLines(row) {
51
+ const glyph = row.verdict === "pass" ? "✔" : row.verdict === "fail" ? "✗" : "?";
52
+ return [`${glyph} ${row.name}: ${row.verdict} — ${row.message}`];
53
+ }
54
+
55
+ /** A side's identity for prose: its commit prefix, or an honest absence. */
56
+ function describeOrigin(provenance) {
57
+ if (!provenance || typeof provenance.commit !== "string") return "unverified origin";
58
+ const dirty = provenance.dirty ? ", dirty" : "";
59
+ return `${provenance.commit.slice(0, 8)}${dirty}`;
60
+ }
61
+
62
+ /**
63
+ * The whole change report.
64
+ *
65
+ * @param {{change: object, coverage: object}} input `change` is
66
+ * `../commands/change.mjs`'s result payload; `coverage` its coverage block.
67
+ * @returns {string}
68
+ */
69
+ export function formatChangeReport({ change, coverage }) {
70
+ const { intent, baseline, head, reconciliation, constraints, policy } = change;
71
+ const sections = [];
72
+
73
+ sections.push(
74
+ `intent ${intent.file} — base ${intent.base.commit.slice(0, 8)}` +
75
+ (intent.summary === undefined ? "" : `\n "${intent.summary}"`),
76
+ );
77
+ sections.push(
78
+ `baseline ${baseline.path} — ${describeOrigin(baseline.provenance)}, ` +
79
+ `${baseline.records} record${baseline.records === 1 ? "" : "s"}, ` +
80
+ `${baseline.projects} project${baseline.projects === 1 ? "" : "s"}`,
81
+ );
82
+ sections.push(
83
+ `head ${describeOrigin(head.provenance)}, ` +
84
+ `${head.projects} project${head.projects === 1 ? "" : "s"}`,
85
+ );
86
+
87
+ for (const note of coverage.notes ?? []) sections.push(`⚠ ${note}`);
88
+
89
+ const verdictLine = {
90
+ matched: "✔ MATCHED — the delta is exactly the declared change",
91
+ undeclared: "⚠ UNDECLARED — the delta contains changes no declaration covers",
92
+ unfulfilled: "✗ UNFULFILLED — nothing undeclared, but declared changes never happened",
93
+ unproven: "? UNPROVEN — the base identity could not be established",
94
+ }[reconciliation.verdict];
95
+ sections.push(`reconciliation ${verdictLine}`);
96
+ for (const reason of reconciliation.reasons) sections.push(` because: ${reason}`);
97
+
98
+ if (reconciliation.matched.length > 0) {
99
+ sections.push(
100
+ `✔ ${reconciliation.matched.length} declared change${reconciliation.matched.length === 1 ? "" : "s"} observed`,
101
+ ...reconciliation.matched.flatMap(factLines),
102
+ );
103
+ }
104
+ if (reconciliation.unexpected.length > 0) {
105
+ sections.push(
106
+ `! ${reconciliation.unexpected.length} undeclared material change${reconciliation.unexpected.length === 1 ? "" : "s"} — a review signal, not a law verdict`,
107
+ ...reconciliation.unexpected.flatMap(factLines),
108
+ );
109
+ }
110
+ if (reconciliation.missingExpected.length > 0) {
111
+ sections.push(
112
+ `? ${reconciliation.missingExpected.length} declared change${reconciliation.missingExpected.length === 1 ? "" : "s"} never observed`,
113
+ ...reconciliation.missingExpected.flatMap(factLines),
114
+ );
115
+ }
116
+
117
+ if (constraints.length > 0) {
118
+ sections.push("declared constraints");
119
+ sections.push(...constraints.flatMap(constraintLines));
120
+ }
121
+
122
+ // Informational on purpose: this number says what `check` would count right
123
+ // now. It gates nothing here — collapsing it into the intent verdict would
124
+ // hide one signal behind the other.
125
+ sections.push(
126
+ policy.liveViolations === null
127
+ ? `workspace law not evaluated — the run could not prove the base identity (archkeep check remains the authority)`
128
+ : `workspace law ${policy.liveViolations} live violation${policy.liveViolations === 1 ? "" : "s"} under the current law${policy.changedSinceBase ? ", which changed since capture" : ""} — informational; archkeep check remains the authoritative verdict`,
129
+ );
130
+
131
+ // The closing claim always states what was compared, so every outcome —
132
+ // including a full match over an unchanged tree — is a verifiable statement
133
+ // rather than silence.
134
+ const declaredCount =
135
+ intent.declared.projectsAdd +
136
+ intent.declared.projectsRemove +
137
+ intent.declared.edgesAdd +
138
+ intent.declared.edgesRemove;
139
+ sections.push(
140
+ `reconciled ${declaredCount} declared change${declaredCount === 1 ? "" : "s"} and ` +
141
+ `${constraints.length} declared constraint${constraints.length === 1 ? "" : "s"} — ` +
142
+ `base ${describeOrigin(baseline.provenance)} (${baseline.projects} projects, ` +
143
+ `${baseline.records} records) against head ${describeOrigin(head.provenance)} ` +
144
+ `(${head.projects} projects)`,
145
+ );
146
+
147
+ return sections.join("\n");
148
+ }
@@ -6,7 +6,10 @@
6
6
  * Sections render only when they have content — introduced (with waived
7
7
  * annotations), resolved, unchanged (with the occurrences-reduced note where
8
8
  * one applies), unknown (with the reason each identity could not be stated),
9
- * and the unresolvable-import block and the summary line always states what
9
+ * the unresolvable-import block, and the custom-rules block (only when the
10
+ * delta computed one — `../commands/delta.mjs` keeps it absent for a
11
+ * workspace where neither side declares custom rules) — and the summary line
12
+ * always states what
10
13
  * was compared: base and head identity, record and project counts, and the
11
14
  * bucket totals. "No introduced violations" is a claim about a comparison the
12
15
  * reader can verify, never silence (`../../../../AGENTS.md`).
@@ -59,6 +62,71 @@ function unknownLines(entry) {
59
62
  return [` ? ${entry.reason}`];
60
63
  }
61
64
 
65
+ /** One classified custom-finding entry as its report lines. */
66
+ function customFindingLines(entry) {
67
+ const where = entry.project === null ? "" : ` in ${entry.project}`;
68
+ const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
69
+ const lines = [` ${entry.ruleId}${where} (${counts})`];
70
+ if (entry.message !== undefined) lines.push(` ${entry.message}`);
71
+ if (entry.note !== undefined) lines.push(` ${entry.note}`);
72
+ const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
73
+ for (const site of sites) {
74
+ // A custom finding states a position only when its rule stated one — a
75
+ // whole-workspace finding has no file, and no line is printed for it.
76
+ if (site.file !== undefined) lines.push(` at ${site.file}:${site.line}:${site.column}`);
77
+ }
78
+ return lines;
79
+ }
80
+
81
+ /** One unknown custom entry — the rule name plus the mandatory reason. */
82
+ function customUnknownLines(entry) {
83
+ return [` ? ${entry.rule}: ${entry.reason}`];
84
+ }
85
+
86
+ /**
87
+ * The custom-rules block, rendered only when the delta computed one — a
88
+ * workspace where neither side declares custom rules keeps the exact report
89
+ * it already had.
90
+ *
91
+ * @param {{judged: object[], skipped: object[], removed: string[],
92
+ * findings: object}} customRules
93
+ * @param {{customFindings: {introduced: number, resolved: number,
94
+ * unchanged: number, unknown: number}}} summary
95
+ * @returns {string[]}
96
+ */
97
+ function customRulesSections(customRules, summary) {
98
+ const { judged, skipped, removed, findings } = customRules;
99
+ const counts = summary.customFindings;
100
+ const lines = [
101
+ `custom rules (${judged.length} judged, ${skipped.length} skipped, ${removed.length} removed)`,
102
+ ];
103
+ lines.push(
104
+ ...section(
105
+ ` ⚠ ${counts.introduced} introduced custom finding${counts.introduced === 1 ? "" : "s"}`,
106
+ findings.introduced.map(customFindingLines),
107
+ ),
108
+ );
109
+ lines.push(
110
+ ...section(
111
+ ` ✔ ${counts.resolved} resolved custom finding${counts.resolved === 1 ? "" : "s"}`,
112
+ findings.resolved.map(customFindingLines),
113
+ ),
114
+ );
115
+ lines.push(
116
+ ...section(
117
+ ` = ${counts.unchanged} unchanged custom finding${counts.unchanged === 1 ? "" : "s"}`,
118
+ findings.unchanged.map(customFindingLines),
119
+ ),
120
+ );
121
+ lines.push(
122
+ ...section(
123
+ ` ? ${counts.unknown} unclassifiable custom item${counts.unknown === 1 ? "" : "s"}`,
124
+ findings.unknown.map(customUnknownLines),
125
+ ),
126
+ );
127
+ return lines;
128
+ }
129
+
62
130
  /**
63
131
  * One classification bucket as a section, or nothing when it is empty.
64
132
  *
@@ -162,6 +230,10 @@ export function formatDeltaReport({ delta, coverage }) {
162
230
  );
163
231
  }
164
232
 
233
+ if (delta.customRules !== undefined) {
234
+ sections.push(...customRulesSections(delta.customRules, summary));
235
+ }
236
+
165
237
  // The closing claim always states what was compared, so an empty delta is a
166
238
  // verifiable statement rather than silence.
167
239
  const compared =
@@ -178,6 +250,15 @@ export function formatDeltaReport({ delta, coverage }) {
178
250
  `waived — ${compared}`,
179
251
  );
180
252
  }
253
+ // The custom gate's own closing claim, so a delta whose only introduction is
254
+ // a custom finding does not end on a line reading clean.
255
+ if (delta.customRules !== undefined && summary.customFindings.introduced > 0) {
256
+ const count = summary.customFindings.introduced;
257
+ sections.push(
258
+ `⚠ ${count} introduced custom finding${count === 1 ? "" : "s"} — custom findings have ` +
259
+ `no waiver lane, every one gates`,
260
+ );
261
+ }
181
262
 
182
263
  return sections.join("\n");
183
264
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The terminal report for the `evolution` command: the selected revisions in
3
+ * history order, each transition classified, and what the whole record can
4
+ * and cannot say.
5
+ *
6
+ * Like `history-text.mjs`, counts end every section so a reader never decides
7
+ * whether an omission is content or silence — and the summary line names the
8
+ * range the record is a claim ABOUT, because "how the architecture evolved"
9
+ * without naming the compared revisions is not a reproducible claim.
10
+ *
11
+ * This module decides nothing. A formatter that filtered would be a rule
12
+ * wearing a formatter's name (`../README.md`); the transition formatters are
13
+ * shared with `history-text.mjs` (`./snapshot-text.mjs`) so both commands
14
+ * render one classification the same way.
15
+ */
16
+
17
+ import { formatChanges, sanitize, transitionKind } from "./snapshot-text.mjs";
18
+
19
+ /**
20
+ * The whole evolution report.
21
+ *
22
+ * @param {{result: {base: string, head: string,
23
+ * revisions: {commit: string, id: string}[],
24
+ * transitions: {from: string, to: string, architectureChanged: boolean,
25
+ * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
26
+ * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
27
+ * @returns {string}
28
+ */
29
+ export function formatEvolutionReport({ result, coverage }) {
30
+ const sections = [];
31
+
32
+ const transitionWord = result.transitions.length === 1 ? "transition" : "transitions";
33
+ const revisionWord = result.revisions.length === 1 ? "revision" : "revisions";
34
+ const inspected = `${coverage.imports} import${
35
+ coverage.imports === 1 ? "" : "s"
36
+ } in ${coverage.analyzedFiles} file${
37
+ coverage.analyzedFiles === 1 ? "" : "s"
38
+ } across ${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
39
+
40
+ sections.push(
41
+ `evolution ${sanitize(result.base.slice(0, 12))}..${sanitize(result.head.slice(0, 12))}`,
42
+ );
43
+ sections.push(
44
+ `${result.revisions.length} ${revisionWord}, ${result.transitions.length} ${transitionWord} (${inspected})`,
45
+ );
46
+
47
+ for (const [i, revision] of result.revisions.entries()) {
48
+ sections.push(`${i} ${sanitize(revision.commit)} ${revision.id.slice(0, 8)}`);
49
+ }
50
+
51
+ // The footer counts true architectural change only — the same discipline as
52
+ // `history-text.mjs`: a policy or provider transition is a change to how the
53
+ // record reads, not to the architecture itself.
54
+ let changed = 0;
55
+ for (const transition of result.transitions) {
56
+ if (transition.architectureChanged) changed += 1;
57
+ const kind = transitionKind(transition);
58
+ sections.push(`~ ${sanitize(transition.from)} → ${sanitize(transition.to)} (${kind})`);
59
+ if (transition.changes) {
60
+ for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
61
+ }
62
+ for (const note of transition.notes) {
63
+ sections.push(` ${note}`);
64
+ }
65
+ }
66
+
67
+ if (changed === 0) {
68
+ const anySignal = result.transitions.some(
69
+ (t) => t.policyChanged === true || t.providerChanged || t.codeDrift,
70
+ );
71
+ sections.push(
72
+ anySignal
73
+ ? "✔ no architectural change across the selected revisions (only policy, provider, or drift signals)"
74
+ : "✔ no change at all across the selected revisions",
75
+ );
76
+ } else {
77
+ sections.push(
78
+ `${changed} transition${changed === 1 ? "" : "s"} recorded an architectural change`,
79
+ );
80
+ }
81
+
82
+ return sections.join("\n");
83
+ }
@@ -7,122 +7,12 @@
7
7
  * actually holds. Counts end every section, so a reader is never left deciding
8
8
  * whether an omission is content or silence.
9
9
  *
10
- * This module decides nothing. A formatter that filtered would be a rule
11
- * wearing a formatter's name (`../README.md`).
10
+ * The per-transition formatters live in `./snapshot-text.mjs`, beside the
11
+ * ones `evolution-text.mjs` renders from — one home for the way a transition
12
+ * becomes prose, so the two commands cannot disagree about it.
12
13
  */
13
14
 
14
- /**
15
- * Neutralises control and terminal-escape sequences in a name or value before
16
- * it is printed, so a crafted project/tag/edge name cannot inject escape
17
- * sequences into a consumer's terminal (`SECURITY.md`). Real project names are
18
- * ordinary characters and pass through untouched; only C0 control characters
19
- * (which includes the ESC byte) and DEL become visible escapes.
20
- *
21
- * @param {string} text
22
- * @returns {string}
23
- */
24
- function sanitize(text) {
25
- // eslint-disable-next-line no-control-regex
26
- return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
27
- if (c === "\n") return "\\n";
28
- if (c === "\t") return "\\t";
29
- if (c === "\r") return "\\r";
30
- return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
31
- });
32
- }
33
-
34
- /**
35
- * One project as a line, same shape as `graph-text.mjs`.
36
- *
37
- * @param {{name: string, root: string, tags: string[]}} project
38
- * @returns {string}
39
- */
40
- function formatProject(project) {
41
- const tags =
42
- project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
43
- return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
44
- }
45
-
46
- /**
47
- * One edge as a line, same shape as `graph-text.mjs`.
48
- *
49
- * @param {{source: string, target: string, type: string}} edge
50
- * @returns {string}
51
- */
52
- function formatEdge(edge) {
53
- return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
54
- }
55
-
56
- /**
57
- * One metadata change as a line, same shape as `diff-text.mjs`.
58
- *
59
- * @param {{field: string, baseline: *, head: *}} change
60
- * @returns {string}
61
- */
62
- function formatChange(change) {
63
- const formatValue = (v) => {
64
- if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
65
- if (v === null || v === undefined) return "(none)";
66
- return sanitize(String(v));
67
- };
68
- return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
69
- }
70
-
71
- /**
72
- * How the architecture actually changed between two snapshots: the added and
73
- * removed projects and edges rendered as one line each. Changed projects
74
- * render their changed fields beneath the project line, like `diff`.
75
- *
76
- * @param {object} changes The `computeDiff` payload.
77
- * @returns {string[]}
78
- */
79
- function formatChanges(changes) {
80
- const lines = [];
81
- if (changes.addedProjects.length > 0) {
82
- const word = changes.addedProjects.length === 1 ? "project" : "projects";
83
- lines.push(`+ ${changes.addedProjects.length} added ${word}`);
84
- for (const project of changes.addedProjects) lines.push(formatProject(project));
85
- }
86
- if (changes.removedProjects.length > 0) {
87
- const word = changes.removedProjects.length === 1 ? "project" : "projects";
88
- lines.push(`- ${changes.removedProjects.length} removed ${word}`);
89
- for (const project of changes.removedProjects) lines.push(formatProject(project));
90
- }
91
- if (changes.changedProjects.length > 0) {
92
- const word = changes.changedProjects.length === 1 ? "project" : "projects";
93
- lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
94
- for (const project of changes.changedProjects) {
95
- lines.push(` ${project.name}`);
96
- for (const change of project.changes) lines.push(formatChange(change));
97
- }
98
- }
99
- if (changes.addedEdges.length > 0) {
100
- const word = changes.addedEdges.length === 1 ? "edge" : "edges";
101
- lines.push(`+ ${changes.addedEdges.length} added ${word}`);
102
- for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
103
- }
104
- if (changes.removedEdges.length > 0) {
105
- const word = changes.removedEdges.length === 1 ? "edge" : "edges";
106
- lines.push(`- ${changes.removedEdges.length} removed ${word}`);
107
- for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
108
- }
109
- return lines;
110
- }
111
-
112
- /**
113
- * Classifies one transition into the short "kind" a reader skims for.
114
- *
115
- * @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
116
- * providerChanged: boolean}} transition
117
- * @returns {string}
118
- */
119
- function transitionKind(transition) {
120
- if (transition.architectureChanged) return "architecture";
121
- if (transition.providerChanged) return "provider";
122
- if (transition.policyChanged === true) return "policy";
123
- if (transition.codeDrift) return "code drift";
124
- return "unchanged";
125
- }
15
+ import { formatChanges, transitionKind } from "./snapshot-text.mjs";
126
16
 
127
17
  /**
128
18
  * The whole history report.