@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.
- package/README.md +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- 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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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.
|