@ecoma-io/archkeep 0.13.0 → 0.15.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 +9 -3
- package/cli.mjs +599 -55
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +70 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +84 -17
- package/src/commands/context.mjs +92 -16
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +664 -0
- package/src/commands/delta-snapshot.mjs +672 -0
- package/src/commands/delta.mjs +606 -0
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +93 -1
- package/src/commands/trajectory.mjs +437 -0
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/path-util.mjs +40 -0
- package/src/providers/native/model.mjs +17 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +264 -0
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +280 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/text.mjs +36 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/report/waivers-text.mjs +35 -2
- package/src/tsconfig-paths.mjs +3 -2
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `delta` command: how boundary violations moved
|
|
3
|
+
* between a captured baseline and the current tree, both re-judged under the
|
|
4
|
+
* current law (`../commands/delta.mjs`).
|
|
5
|
+
*
|
|
6
|
+
* Sections render only when they have content — introduced (with waived
|
|
7
|
+
* annotations), resolved, unchanged (with the occurrences-reduced note where
|
|
8
|
+
* one applies), unknown (with the reason each identity could not be stated),
|
|
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
|
|
13
|
+
* was compared: base and head identity, record and project counts, and the
|
|
14
|
+
* bucket totals. "No introduced violations" is a claim about a comparison the
|
|
15
|
+
* reader can verify, never silence (`../../../../AGENTS.md`).
|
|
16
|
+
*
|
|
17
|
+
* `coverage.notes` — the policy-changed, dirty-tree, and provenance warnings
|
|
18
|
+
* `../commands/delta.mjs` pushes there — fold into the report as their own
|
|
19
|
+
* lines, so a note that rides the JSON envelope also reaches the terminal.
|
|
20
|
+
*
|
|
21
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
22
|
+
* wearing a formatter's name (`./README.md`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A side's identity for prose: its commit prefix, or an honest absence. */
|
|
26
|
+
function describeOrigin(provenance) {
|
|
27
|
+
if (!provenance || typeof provenance.commit !== "string") return "unverified origin";
|
|
28
|
+
const dirty = provenance.dirty ? ", dirty" : "";
|
|
29
|
+
return `${provenance.commit.slice(0, 8)}${dirty}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One classified violation entry as its report lines. */
|
|
33
|
+
function violationLines(entry) {
|
|
34
|
+
const source = entry.sourceProject ?? "(unattributed)";
|
|
35
|
+
const arrow = entry.targetIsSpecifier ? "⇢" : "→";
|
|
36
|
+
const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
|
|
37
|
+
const waived = entry.waived === true ? " [waived]" : "";
|
|
38
|
+
const lines = [` ${source} ${arrow} ${entry.target} ${entry.messageId} (${counts})${waived}`];
|
|
39
|
+
if (entry.note !== undefined) lines.push(` ${entry.note}`);
|
|
40
|
+
const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
|
|
41
|
+
for (const site of sites) {
|
|
42
|
+
lines.push(` at ${site.file}:${site.line}:${site.column}`);
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One classified unresolvable-record entry as its report lines. */
|
|
48
|
+
function unresolvableLines(entry) {
|
|
49
|
+
const source = entry.sourceProject ?? "(unattributed)";
|
|
50
|
+
const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
|
|
51
|
+
const lines = [` ${source} ⇢ ${entry.specifier} (${entry.kind || "unknown kind"}; ${counts})`];
|
|
52
|
+
if (entry.note !== undefined) lines.push(` ${entry.note}`);
|
|
53
|
+
const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
|
|
54
|
+
for (const site of sites) {
|
|
55
|
+
lines.push(` at ${site.file}:${site.line}:${site.column}`);
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One unknown entry — the reason is the load-bearing half. */
|
|
61
|
+
function unknownLines(entry) {
|
|
62
|
+
return [` ? ${entry.reason}`];
|
|
63
|
+
}
|
|
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
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One classification bucket as a section, or nothing when it is empty.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} heading Rendered above the entries, already carrying its count.
|
|
134
|
+
* @param {string[][]} entryLines
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
137
|
+
function section(heading, entryLines) {
|
|
138
|
+
if (entryLines.length === 0) return [];
|
|
139
|
+
return [heading, ...entryLines.flat()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The whole delta report.
|
|
144
|
+
*
|
|
145
|
+
* @param {{delta: object, coverage: object}} input `delta` is
|
|
146
|
+
* `../commands/delta.mjs`'s result payload; `coverage` its coverage block.
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function formatDeltaReport({ delta, coverage }) {
|
|
150
|
+
const sections = [];
|
|
151
|
+
const { baseline, head, summary, violations, unresolvable } = delta;
|
|
152
|
+
|
|
153
|
+
sections.push(
|
|
154
|
+
`baseline ${baseline.path} — ${describeOrigin(baseline.provenance)}, ` +
|
|
155
|
+
`${baseline.records} record${baseline.records === 1 ? "" : "s"}, ` +
|
|
156
|
+
`${baseline.projects} project${baseline.projects === 1 ? "" : "s"}`,
|
|
157
|
+
);
|
|
158
|
+
sections.push(
|
|
159
|
+
`head ${describeOrigin(head.provenance)}, ` +
|
|
160
|
+
`${head.records} record${head.records === 1 ? "" : "s"}, ` +
|
|
161
|
+
`${head.projects} project${head.projects === 1 ? "" : "s"}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
for (const note of coverage.notes ?? []) {
|
|
165
|
+
sections.push(`⚠ ${note}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const introducedWord = summary.introduced === 1 ? "violation" : "violations";
|
|
169
|
+
sections.push(
|
|
170
|
+
...section(
|
|
171
|
+
`⚠ ${summary.introduced} introduced ${introducedWord}` +
|
|
172
|
+
(summary.introducedWaived > 0
|
|
173
|
+
? ` (${summary.introducedWaived} waived — reported, not gating)`
|
|
174
|
+
: ""),
|
|
175
|
+
violations.introduced.map(violationLines),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
sections.push(
|
|
179
|
+
...section(
|
|
180
|
+
`✔ ${summary.resolved} resolved ${summary.resolved === 1 ? "violation" : "violations"}`,
|
|
181
|
+
violations.resolved.map(violationLines),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
sections.push(
|
|
185
|
+
...section(
|
|
186
|
+
`= ${summary.unchanged} unchanged ${summary.unchanged === 1 ? "violation" : "violations"}`,
|
|
187
|
+
violations.unchanged.map(violationLines),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
sections.push(
|
|
191
|
+
...section(
|
|
192
|
+
`? ${summary.unknown} unclassifiable ${summary.unknown === 1 ? "item" : "items"}`,
|
|
193
|
+
violations.unknown.map(unknownLines),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const unresolvableTotal =
|
|
198
|
+
summary.unresolvable.introduced +
|
|
199
|
+
summary.unresolvable.resolved +
|
|
200
|
+
summary.unresolvable.unchanged +
|
|
201
|
+
summary.unresolvable.unknown;
|
|
202
|
+
if (unresolvableTotal > 0) {
|
|
203
|
+
sections.push(
|
|
204
|
+
`unresolvable imports (carried, never counted as violations — no rule reached a verdict ` +
|
|
205
|
+
`about them)`,
|
|
206
|
+
);
|
|
207
|
+
sections.push(
|
|
208
|
+
...section(
|
|
209
|
+
` + ${summary.unresolvable.introduced} introduced`,
|
|
210
|
+
unresolvable.introduced.map(unresolvableLines),
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
sections.push(
|
|
214
|
+
...section(
|
|
215
|
+
` - ${summary.unresolvable.resolved} resolved`,
|
|
216
|
+
unresolvable.resolved.map(unresolvableLines),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
sections.push(
|
|
220
|
+
...section(
|
|
221
|
+
` = ${summary.unresolvable.unchanged} unchanged`,
|
|
222
|
+
unresolvable.unchanged.map(unresolvableLines),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
sections.push(
|
|
226
|
+
...section(
|
|
227
|
+
` ? ${summary.unresolvable.unknown} unclassifiable`,
|
|
228
|
+
unresolvable.unknown.map(unknownLines),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (delta.customRules !== undefined) {
|
|
234
|
+
sections.push(...customRulesSections(delta.customRules, summary));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The closing claim always states what was compared, so an empty delta is a
|
|
238
|
+
// verifiable statement rather than silence.
|
|
239
|
+
const compared =
|
|
240
|
+
`compared baseline ${describeOrigin(baseline.provenance)} ` +
|
|
241
|
+
`(${baseline.records} record${baseline.records === 1 ? "" : "s"}) against head ` +
|
|
242
|
+
`${describeOrigin(head.provenance)} (${head.records} record${head.records === 1 ? "" : "s"}, ` +
|
|
243
|
+
`${coverage.analyzedFiles} analyzed file${coverage.analyzedFiles === 1 ? "" : "s"}, ` +
|
|
244
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"})`;
|
|
245
|
+
if (summary.introduced === 0) {
|
|
246
|
+
sections.push(`✔ no introduced violations — ${compared}`);
|
|
247
|
+
} else {
|
|
248
|
+
sections.push(
|
|
249
|
+
`${summary.introduced - summary.introducedWaived} introduced ${introducedWord} not ` +
|
|
250
|
+
`waived — ${compared}`,
|
|
251
|
+
);
|
|
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
|
+
}
|
|
262
|
+
|
|
263
|
+
return sections.join("\n");
|
|
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
|
+
}
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - the constraint row(s) that matched (which rule applied)
|
|
16
16
|
* - the verdict (violation or allowed)
|
|
17
17
|
* - the message, when there is one (what the verdict means in prose)
|
|
18
|
+
* - for a violation: the allowed direction when the governing row states one,
|
|
19
|
+
* and a remediation line that is the author's declared guidance verbatim —
|
|
20
|
+
* or, when none is declared, an explicit pointer at the constraint row and
|
|
21
|
+
* its `decisionRef`, never a fix this renderer composed
|
|
18
22
|
* - coverage information (whether this explanation is complete)
|
|
19
23
|
*
|
|
20
24
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
@@ -131,8 +135,31 @@ export function formatExplainReport({ explanation, coverage }) {
|
|
|
131
135
|
if (v.constraint?.description) {
|
|
132
136
|
sections.push(`${DETAIL}rule ${v.constraint.description}`);
|
|
133
137
|
}
|
|
138
|
+
// The allowed direction, verbatim from the governing row when it
|
|
139
|
+
// states one. A `notDependOnLibsWithTags` row states no allowed list,
|
|
140
|
+
// and computing its complement here would be this renderer inventing
|
|
141
|
+
// a direction the law never wrote — the constraint line above is the
|
|
142
|
+
// honest answer there, so no line is printed.
|
|
143
|
+
if (Array.isArray(v.constraint?.onlyDependOnLibsWithTags)) {
|
|
144
|
+
sections.push(
|
|
145
|
+
`${DETAIL}allowed ${formatTags(v.constraint.onlyDependOnLibsWithTags)}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
// The remediation line is always printed for a violation: the
|
|
149
|
+
// author's declared guidance verbatim, or an explicit pointer at
|
|
150
|
+
// where guidance lives — never a fix this renderer composed, and
|
|
151
|
+
// never silence a reader could mistake for "nothing to consult".
|
|
134
152
|
if (v.constraint?.remediation) {
|
|
135
153
|
sections.push(`${DETAIL}remediation ${v.constraint.remediation}`);
|
|
154
|
+
} else if (v.constraint) {
|
|
155
|
+
const ref = v.constraint.decisionRef
|
|
156
|
+
? ` and its decisionRef ${v.constraint.decisionRef}`
|
|
157
|
+
: "";
|
|
158
|
+
sections.push(`${DETAIL}remediation none declared — consult the constraint row${ref}`);
|
|
159
|
+
} else {
|
|
160
|
+
sections.push(
|
|
161
|
+
`${DETAIL}remediation none declared — no depConstraints row drives this check`,
|
|
162
|
+
);
|
|
136
163
|
}
|
|
137
164
|
}
|
|
138
165
|
} else {
|
|
@@ -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.
|