@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.
Files changed (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. package/src/tsconfig-paths.mjs +3 -2
@@ -680,6 +680,31 @@ export function sarifCoverageGapNotification(gap) {
680
680
  },
681
681
  };
682
682
  }
683
+ // The accepted counterpart: the same bounded sample, saying the state is a
684
+ // recorded acceptance (`coverage.unowned`) rather than an open question —
685
+ // still a warning, because an accepted hole is still a hole, and this face
686
+ // must not be the one that stops saying so.
687
+ if (gap.kind === "accepted-unowned-files") {
688
+ const files = gap.files ?? [];
689
+ const languages = gap.languages ?? [];
690
+ const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
691
+ const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
692
+ const remaining = files.length - shown.length;
693
+ const listed =
694
+ shown.length > 0
695
+ ? `: ${shown.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
696
+ : "";
697
+ return {
698
+ level: "warning",
699
+ message: {
700
+ text:
701
+ `${files.length} tracked analyzable file${files.length === 1 ? "" : "s"}${spans} ` +
702
+ `owned by no project — accepted as coverage holes by the policy's coverage.unowned, ` +
703
+ `so no boundary verdict in this run covers ` +
704
+ `${files.length === 1 ? "it" : "them"}${listed}`,
705
+ },
706
+ };
707
+ }
683
708
  return {
684
709
  level: "warning",
685
710
  message: {
@@ -931,6 +956,261 @@ export function buildSarifLog({
931
956
  };
932
957
  }
933
958
 
959
+ /**
960
+ * One introduced boundary-violation entry, at one of its head sites, as a
961
+ * SARIF result — the `delta` verb's rendering of the same rule catalogue
962
+ * `sarifResult` resolves into.
963
+ *
964
+ * `ruleId` is the entry's `messageId`, spelled exactly, so a delta upload and
965
+ * a `check` upload name the same rule for the same law — no new descriptors,
966
+ * and no second catalogue. The classifier's entry carries no rendered upstream
967
+ * message (`../commands/delta-classify.mjs` keeps identity and evidence, not
968
+ * prose), so the message is composed here from the facts the entry does carry:
969
+ * the classification, both sides' occurrence counts, the edge, and the
970
+ * constraint row — never empty, because GitHub rejects a result whose
971
+ * `message.text` is.
972
+ *
973
+ * One result per HEAD site: an introduced violation exists at head by
974
+ * construction (`headCount > 0`), and the head sites are the lines a reviewer
975
+ * of the change can act on — a base site names code the checkout under review
976
+ * may no longer contain.
977
+ *
978
+ * A waived-introduced entry is still a result (reported, not gating —
979
+ * `../commands/delta.mjs`'s fold), tagged with the same `accepted` vocabulary
980
+ * `sarifResult` uses for a waived violation, so one consumer query covers both
981
+ * verbs' uploads.
982
+ *
983
+ * @param {{messageId: string, sourceProject: string|null, target: string,
984
+ * targetIsSpecifier: boolean, constraint: object|null, baseCount: number,
985
+ * headCount: number, reason?: string, waived?: boolean,
986
+ * waivedBy?: {expiresAt?: string, reason?: string}}} entry One `introduced`
987
+ * entry from `classifyViolations` (`../commands/delta-classify.mjs`).
988
+ * @param {{file: string, line: number, column: number, specifier?: string,
989
+ * kind?: string}} site One of the entry's head sites — 1-based, as the
990
+ * analysis records carry them.
991
+ * @returns {object}
992
+ */
993
+ export function sarifDeltaResult(entry, site) {
994
+ const target = entry.targetIsSpecifier
995
+ ? `specifier ${JSON.stringify(entry.target)}`
996
+ : entry.target;
997
+ const text =
998
+ `Introduced by this change: ${entry.messageId} — ` +
999
+ `from ${entry.sourceProject ?? "(no project)"} to ${target} ` +
1000
+ `(${entry.baseCount} occurrence${entry.baseCount === 1 ? "" : "s"} at base, ` +
1001
+ `${entry.headCount} at head)` +
1002
+ `${entry.reason ? ` — ${entry.reason}` : ""}. ` +
1003
+ `Constraint: ${formatConstraint(entry.constraint)}`;
1004
+ return {
1005
+ ruleId: entry.messageId,
1006
+ ruleIndex: MESSAGE_IDS.indexOf(entry.messageId),
1007
+ level: "error",
1008
+ message: { text },
1009
+ locations: [
1010
+ {
1011
+ physicalLocation: {
1012
+ artifactLocation: { uri: toUriReference(site.file) },
1013
+ region: { startLine: site.line, startColumn: site.column },
1014
+ },
1015
+ },
1016
+ ],
1017
+ properties: {
1018
+ delta: "introduced",
1019
+ baseCount: entry.baseCount,
1020
+ headCount: entry.headCount,
1021
+ sourceProject: entry.sourceProject,
1022
+ target: entry.target,
1023
+ // The same vocabulary `sarifResult` uses for a waived violation: the
1024
+ // result is still an error (an accepted violation is still a violation),
1025
+ // and the properties say why it does not gate.
1026
+ ...(entry.waived === true
1027
+ ? {
1028
+ accepted: true,
1029
+ acceptedUntil: entry.waivedBy?.expiresAt,
1030
+ acceptedReason: entry.waivedBy?.reason,
1031
+ }
1032
+ : {}),
1033
+ },
1034
+ };
1035
+ }
1036
+
1037
+ /**
1038
+ * The `delta` verb's SARIF log: the INTRODUCED buckets as results, everything
1039
+ * the classification could not answer as tool-execution notifications.
1040
+ *
1041
+ * Results are introduced entries only — the classified boundary violations
1042
+ * (`sarifDeltaResult`, waived ones included and tagged) and the introduced
1043
+ * custom-rule findings, the latter through the same `sarifCustomRuleResult`
1044
+ * and end-of-catalogue descriptors `check`'s log uses, so a custom finding
1045
+ * resolves in `sarifRules(customCatalogue)` here exactly as it does there.
1046
+ * Resolved and unchanged entries are deliberately NOT results: this log is
1047
+ * uploaded against the head checkout, and an annotation for a violation the
1048
+ * change resolved would mark code that no longer contains it.
1049
+ *
1050
+ * The notification lane is the load-bearing half. `delta` exits 3 on any
1051
+ * `unknown` entry (`../commands/delta.mjs`'s fold), and every one of those
1052
+ * rides here — a violation, unresolvable-record, or custom-rule item whose
1053
+ * identity could not be stated — so an exit-3 delta can never upload a log
1054
+ * byte-identical to a clean run's, the same guarantee `buildSarifLog` keeps
1055
+ * for `check`'s no-verdict lanes. Coverage notes (policy drift, dirty trees,
1056
+ * skipped or removed custom rules) and introduced unresolvable records ride
1057
+ * the same lane: none is a verdict, and dropping any of them is the silent
1058
+ * direction (`../../../../AGENTS.md`).
1059
+ *
1060
+ * `executionSuccessful` stays `true` on every status — the run completed and
1061
+ * classified; the findings are results, not tool errors — and `columnKind`
1062
+ * states the analyzers' real UTF-16 convention, both for the reasons the
1063
+ * module header gives.
1064
+ *
1065
+ * @param {{delta: {violations: {introduced: object[], resolved?: object[],
1066
+ * unchanged?: object[], unknown: object[]},
1067
+ * unresolvable: {introduced: object[], resolved?: object[],
1068
+ * unchanged?: object[], unknown: object[]},
1069
+ * customRules?: {findings: {introduced: object[], resolved?: object[],
1070
+ * unchanged?: object[], unknown: object[]}}},
1071
+ * coverage: {notes?: string[]},
1072
+ * customCatalogue?: {ruleId: string, rule: string, findingId: string,
1073
+ * message: string}[]}} run The `deltaCommand` result's `delta` and
1074
+ * `coverage`, plus the head-declared custom-rule catalogue
1075
+ * (`../commands/custom-rules.mjs`'s `customRulesForDelta`) — passed
1076
+ * separately because the envelope deliberately does not carry it.
1077
+ * @returns {object} A SARIF 2.1.0 log, ready to `JSON.stringify`.
1078
+ */
1079
+ export function buildDeltaSarifLog({ delta, coverage, customCatalogue = [] }) {
1080
+ const customRuleIndex = new Map(
1081
+ customCatalogue.map((entry, index) => [entry.ruleId, CUSTOM_RULE_INDEX_BASE + index]),
1082
+ );
1083
+ const customIntroduced = delta.customRules?.findings.introduced ?? [];
1084
+ const results = [
1085
+ ...delta.violations.introduced.flatMap((entry) =>
1086
+ entry.headSites.map((site) => sarifDeltaResult(entry, site)),
1087
+ ),
1088
+ ...customIntroduced.flatMap((entry) =>
1089
+ entry.headSites.map((site) => {
1090
+ const rendered = sarifCustomRuleResult(
1091
+ {
1092
+ id: entry.ruleId,
1093
+ message:
1094
+ `Introduced by this change: custom rule finding ${entry.ruleId} ` +
1095
+ `(${entry.baseCount} occurrence${entry.baseCount === 1 ? "" : "s"} at base, ` +
1096
+ `${entry.headCount} at head)` +
1097
+ `${entry.reason ? ` — ${entry.reason}` : ""}` +
1098
+ `${typeof entry.message === "string" && entry.message !== "" ? `: ${entry.message}` : ""}`,
1099
+ ...(typeof site.file === "string" ? { sourceFile: site.file } : {}),
1100
+ ...(typeof site.line === "number" ? { line: site.line } : {}),
1101
+ ...(typeof site.column === "number" ? { column: site.column } : {}),
1102
+ ...(entry.project === null ? {} : { project: entry.project }),
1103
+ },
1104
+ // `?? -1` for the same reason `buildSarifLog` gives: an introduced
1105
+ // finding whose id its own head catalogue does not declare cannot
1106
+ // arrive here, and a regression in that guarantee must be a visibly
1107
+ // broken index rather than an `undefined` GitHub ignores.
1108
+ customRuleIndex.get(entry.ruleId) ?? -1,
1109
+ );
1110
+ return {
1111
+ ...rendered,
1112
+ properties: {
1113
+ ...(rendered.properties ?? {}),
1114
+ delta: "introduced",
1115
+ baseCount: entry.baseCount,
1116
+ headCount: entry.headCount,
1117
+ },
1118
+ };
1119
+ }),
1120
+ ),
1121
+ ];
1122
+ const toolExecutionNotifications = [
1123
+ ...delta.violations.unknown.map((entry) => ({
1124
+ level: "warning",
1125
+ message: {
1126
+ text: `delta could not classify a violation — ${entry.reason}. The run reaches no verdict.`,
1127
+ },
1128
+ })),
1129
+ ...delta.unresolvable.unknown.map((entry) => ({
1130
+ level: "warning",
1131
+ message: {
1132
+ text:
1133
+ `delta could not classify an unresolvable import site — ${entry.reason}. ` +
1134
+ `The run reaches no verdict.`,
1135
+ },
1136
+ })),
1137
+ ...(delta.customRules?.findings.unknown ?? []).map((entry) => ({
1138
+ level: "warning",
1139
+ message: {
1140
+ text:
1141
+ `delta could not classify a custom-rule item` +
1142
+ `${typeof entry.rule === "string" ? ` (rule "${entry.rule}")` : ""} — ` +
1143
+ `${entry.reason}. The run reaches no verdict.`,
1144
+ },
1145
+ })),
1146
+ // Introduced unresolvable records: sites the change added whose target
1147
+ // analysis could not resolve. Never results — no rule reached a verdict
1148
+ // about them — but a change that adds them must not upload the log a
1149
+ // change that adds nothing would.
1150
+ ...delta.unresolvable.introduced.map((entry) => ({
1151
+ level: "warning",
1152
+ message: {
1153
+ text:
1154
+ `This change introduces ${entry.headCount - entry.baseCount} unresolvable import ` +
1155
+ `site${entry.headCount - entry.baseCount === 1 ? "" : "s"} for specifier ` +
1156
+ `${JSON.stringify(entry.specifier)} (${entry.kind || "unknown kind"})` +
1157
+ `${entry.sourceProject === null ? "" : ` in ${entry.sourceProject}`} — ` +
1158
+ `no rule reached a verdict about ${entry.headCount - entry.baseCount === 1 ? "it" : "them"}`,
1159
+ },
1160
+ locations: (entry.headSites ?? [])
1161
+ .filter((site) => typeof site.file === "string")
1162
+ .map((site) => ({
1163
+ physicalLocation: {
1164
+ artifactLocation: { uri: toUriReference(site.file) },
1165
+ ...(typeof site.line === "number"
1166
+ ? {
1167
+ region: {
1168
+ startLine: site.line,
1169
+ ...(typeof site.column === "number" ? { startColumn: site.column } : {}),
1170
+ },
1171
+ }
1172
+ : {}),
1173
+ },
1174
+ })),
1175
+ })),
1176
+ ...(coverage.notes ?? []).map((note) => ({
1177
+ level: "warning",
1178
+ message: { text: `Coverage note: ${note}` },
1179
+ })),
1180
+ ];
1181
+ return {
1182
+ $schema: SARIF_SCHEMA,
1183
+ version: SARIF_VERSION,
1184
+ runs: [
1185
+ {
1186
+ tool: { driver: { name: "archkeep", rules: sarifRules(customCatalogue) } },
1187
+ columnKind: "utf16CodeUnits",
1188
+ results,
1189
+ invocations: [
1190
+ {
1191
+ // True even on exit 1 or 3: the classification completed and said
1192
+ // what it could not answer — the same reasoning `buildSarifLog`
1193
+ // states for `check`.
1194
+ executionSuccessful: true,
1195
+ toolExecutionNotifications,
1196
+ },
1197
+ ],
1198
+ },
1199
+ ],
1200
+ };
1201
+ }
1202
+
1203
+ /**
1204
+ * The delta SARIF log as the bytes to write — pretty-printed with a trailing
1205
+ * newline, the same presentation `formatSarif` gives `check`'s log.
1206
+ *
1207
+ * @param {Parameters<typeof buildDeltaSarifLog>[0]} run
1208
+ * @returns {string}
1209
+ */
1210
+ export function formatDeltaSarif(run) {
1211
+ return `${JSON.stringify(buildDeltaSarifLog(run), null, 2)}\n`;
1212
+ }
1213
+
934
1214
  /**
935
1215
  * The SARIF log as the bytes to write — pretty-printed with a trailing newline,
936
1216
  * so a file that lands in a diff or a log stays readable.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Formatters two snapshot-transition reports share — `history-text.mjs` and
3
+ * `evolution-text.mjs` render the same transition shape (a graph diff, notes,
4
+ * and a short kind label), and a second copy of these helpers is where the
5
+ * two renders would drift: one day "code drift" means one thing in `history`
6
+ * and another in `evolution`, and no gate compares rendered prose.
7
+ *
8
+ * This module decides nothing. A formatter that filtered would be a rule
9
+ * wearing a formatter's name (`../README.md`).
10
+ */
11
+
12
+ /**
13
+ * Neutralises control and terminal-escape sequences in a name or value before
14
+ * it is printed, so a crafted project/tag/edge name cannot inject escape
15
+ * sequences into a consumer's terminal (`SECURITY.md`). Real project names are
16
+ * ordinary characters and pass through untouched; only C0 control characters
17
+ * (which includes the ESC byte) and DEL become visible escapes.
18
+ *
19
+ * @param {string} text
20
+ * @returns {string}
21
+ */
22
+ export function sanitize(text) {
23
+ // eslint-disable-next-line no-control-regex
24
+ return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
25
+ if (c === "\n") return "\\n";
26
+ if (c === "\t") return "\\t";
27
+ if (c === "\r") return "\\r";
28
+ return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
29
+ });
30
+ }
31
+
32
+ /**
33
+ * One project as a line, same shape as `graph-text.mjs`.
34
+ *
35
+ * @param {{name: string, root: string, tags: string[]}} project
36
+ * @returns {string}
37
+ */
38
+ export function formatProject(project) {
39
+ const tags =
40
+ project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
41
+ return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
42
+ }
43
+
44
+ /**
45
+ * One edge as a line, same shape as `graph-text.mjs`.
46
+ *
47
+ * @param {{source: string, target: string, type: string}} edge
48
+ * @returns {string}
49
+ */
50
+ export function formatEdge(edge) {
51
+ return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
52
+ }
53
+
54
+ /**
55
+ * One metadata change as a line, same shape as `diff-text.mjs`.
56
+ *
57
+ * @param {{field: string, baseline: *, head: *}} change
58
+ * @returns {string}
59
+ */
60
+ export function formatChange(change) {
61
+ const formatValue = (v) => {
62
+ if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
63
+ if (v === null || v === undefined) return "(none)";
64
+ return sanitize(String(v));
65
+ };
66
+ return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
67
+ }
68
+
69
+ /**
70
+ * How the architecture actually changed between two snapshots: the added and
71
+ * removed projects and edges rendered as one line each. Changed projects
72
+ * render their changed fields beneath the project line, like `diff`.
73
+ *
74
+ * @param {object} changes The `computeDiff` payload.
75
+ * @returns {string[]}
76
+ */
77
+ export function formatChanges(changes) {
78
+ const lines = [];
79
+ if (changes.addedProjects.length > 0) {
80
+ const word = changes.addedProjects.length === 1 ? "project" : "projects";
81
+ lines.push(`+ ${changes.addedProjects.length} added ${word}`);
82
+ for (const project of changes.addedProjects) lines.push(formatProject(project));
83
+ }
84
+ if (changes.removedProjects.length > 0) {
85
+ const word = changes.removedProjects.length === 1 ? "project" : "projects";
86
+ lines.push(`- ${changes.removedProjects.length} removed ${word}`);
87
+ for (const project of changes.removedProjects) lines.push(formatProject(project));
88
+ }
89
+ if (changes.changedProjects.length > 0) {
90
+ const word = changes.changedProjects.length === 1 ? "project" : "projects";
91
+ lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
92
+ for (const project of changes.changedProjects) {
93
+ lines.push(` ${project.name}`);
94
+ for (const change of project.changes) lines.push(formatChange(change));
95
+ }
96
+ }
97
+ if (changes.addedEdges.length > 0) {
98
+ const word = changes.addedEdges.length === 1 ? "edge" : "edges";
99
+ lines.push(`+ ${changes.addedEdges.length} added ${word}`);
100
+ for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
101
+ }
102
+ if (changes.removedEdges.length > 0) {
103
+ const word = changes.removedEdges.length === 1 ? "edge" : "edges";
104
+ lines.push(`- ${changes.removedEdges.length} removed ${word}`);
105
+ for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
106
+ }
107
+ return lines;
108
+ }
109
+
110
+ /**
111
+ * Classifies one transition into the short "kind" a reader skims for.
112
+ *
113
+ * @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
114
+ * providerChanged: boolean}} transition
115
+ * @returns {string}
116
+ */
117
+ export function transitionKind(transition) {
118
+ if (transition.architectureChanged) return "architecture";
119
+ if (transition.providerChanged) return "provider";
120
+ if (transition.policyChanged === true) return "policy";
121
+ if (transition.codeDrift) return "code drift";
122
+ return "unchanged";
123
+ }
@@ -588,6 +588,7 @@ const UNOWNED_SAMPLE_LIMIT = 10;
588
588
  function formatCoverageGap(gap) {
589
589
  if (gap.kind === "unregistered-plugin") return formatUnregisteredPluginGap(gap);
590
590
  if (gap.kind === "unowned-files") return formatUnownedFilesGap(gap);
591
+ if (gap.kind === "accepted-unowned-files") return formatAcceptedUnownedFilesGap(gap);
591
592
  return `⚠ coverage gap "${gap.kind}" — part of this workspace is outside what this run covered`;
592
593
  }
593
594
 
@@ -655,6 +656,41 @@ function formatUnownedFilesGap(gap) {
655
656
  );
656
657
  }
657
658
 
659
+ /**
660
+ * The accepted counterpart of the gap above: unowned files the boundary
661
+ * policy's `coverage.unowned` rows accept (`../commands/check.mjs`,
662
+ * `../commands/coverage-acceptance.mjs`). An accepted hole is still a hole,
663
+ * so the section renders on every run the acceptance is in force, with the
664
+ * same bounded sample the warning uses — what it no longer does is ask the
665
+ * reader a question someone already answered, which is the warning's job.
666
+ * The reasons live on the accepting rows, and `archkeep waivers` is the
667
+ * surface that names each row with its reason and current coverage; this
668
+ * face points there rather than restating them.
669
+ *
670
+ * @param {{languages?: string[], files: string[]}} gap
671
+ * @returns {string}
672
+ */
673
+ function formatAcceptedUnownedFilesGap(gap) {
674
+ const files = gap.files ?? [];
675
+ const count = files.length;
676
+ const languages = gap.languages ?? [];
677
+ const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
678
+ const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
679
+ const remaining = count - shown.length;
680
+ const lines = shown.map((file) => `${CONTINUED}${file}`);
681
+ if (remaining > 0) {
682
+ lines.push(`${CONTINUED}… and ${remaining} more — the full list is in --format json`);
683
+ }
684
+ const them = count === 1 ? "it" : "them";
685
+ return (
686
+ `⚠ ${count} tracked analyzable file${count === 1 ? "" : "s"}${spans} ` +
687
+ `owned by no project — accepted as coverage holes by the policy's coverage.unowned, ` +
688
+ `so no boundary verdict covers ${them}\n` +
689
+ `${lines.join("\n")}\n` +
690
+ `${DETAIL}each accepting row's reason: archkeep waivers`
691
+ );
692
+ }
693
+
658
694
  /**
659
695
  * The policy-identity line — which law this run enforced — rendered FIRST,
660
696
  * ahead of every verdict below it: a reader has to know WHICH law produced a
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The terminal report for the `trajectory` command: the aggregate signals
3
+ * across a snapshot history, with what each number is a claim about.
4
+ *
5
+ * Every line states counts, never judgments — the report has no "better", no
6
+ * score, no direction adjective. The header names the observation basis (one
7
+ * observation is one stored graph snapshot, not a commit or a day), an
8
+ * insufficient history says so instead of printing zeros, and the disclosures
9
+ * line prints even when every count is zero, so a reader can tell "nothing
10
+ * was incomparable" from "the report forgot to say".
11
+ *
12
+ * This module decides nothing. A formatter that filtered would be a rule
13
+ * wearing a formatter's name (`../README.md`).
14
+ */
15
+
16
+ /**
17
+ * Neutralises control and terminal-escape sequences before anything reaches
18
+ * the terminal (`SECURITY.md`) — the same sanitation every other renderer
19
+ * applies. Only paths and snapshot filenames are printed here; project and
20
+ * edge identities stay aggregated precisely so nothing name-shaped needs to.
21
+ *
22
+ * @param {string} text
23
+ * @returns {string}
24
+ */
25
+ function sanitize(text) {
26
+ // eslint-disable-next-line no-control-regex
27
+ return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
28
+ if (c === "\n") return "\\n";
29
+ if (c === "\t") return "\\t";
30
+ if (c === "\r") return "\\r";
31
+ return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
32
+ });
33
+ }
34
+
35
+ /**
36
+ * A delta with its sign, so +2 / -1 / 0 read as movement rather as bare
37
+ * magnitudes. Zero prints unsigned — it is the absence of movement, not a
38
+ * positive one.
39
+ *
40
+ * @param {number|null} value
41
+ * @returns {string}
42
+ */
43
+ function signed(value) {
44
+ if (value === null) return "n/a";
45
+ if (value > 0) return `+${value}`;
46
+ return `${value}`;
47
+ }
48
+
49
+ /**
50
+ * A count that is either established or explicitly unavailable. `null`
51
+ * renders as `n/a` beside the reason the header already stated — never as a
52
+ * zero that would claim a measurement.
53
+ *
54
+ * @param {number|null} value
55
+ * @returns {string}
56
+ */
57
+ function counted(value) {
58
+ return value === null ? "n/a" : `${value}`;
59
+ }
60
+
61
+ /**
62
+ * One structural axis as one line: endpoints, then events, then persistence.
63
+ *
64
+ * @param {{first: number, current: number, delta: number|null,
65
+ * addedEvents: number|null, removedEvents: number|null,
66
+ * changedEvents: number|null, introduced: number|null, resolved: number|null,
67
+ * persistent: number|null}} axis
68
+ * @param {boolean} withChanged Whether the axis carries a changed-event count
69
+ * (projects do; edges do not — a type flip is remove+add under the triple
70
+ * identity).
71
+ * @returns {string}
72
+ */
73
+ function formatAxis(axis, withChanged) {
74
+ const parts = [
75
+ `first ${axis.first}`,
76
+ `current ${axis.current}`,
77
+ `delta ${signed(axis.delta)}`,
78
+ `added ${counted(axis.addedEvents)}`,
79
+ `removed ${counted(axis.removedEvents)}`,
80
+ ];
81
+ if (withChanged) parts.push(`changed ${counted(axis.changedEvents)}`);
82
+ parts.push(
83
+ `introduced ${counted(axis.introduced)}`,
84
+ `resolved ${counted(axis.resolved)}`,
85
+ `persistent ${counted(axis.persistent)}`,
86
+ );
87
+ return parts.join(" · ");
88
+ }
89
+
90
+ /**
91
+ * The whole trajectory report.
92
+ *
93
+ * @param {{trajectory: {dir: string, observations: {count: number, basis: string,
94
+ * first: string|null, last: string|null, withProvenance: number,
95
+ * dirtyProvenance: number}, available: boolean, unavailableReason: string|null,
96
+ * transitions: {count: number, architecture: number, policy: number,
97
+ * provider: number, codeDrift: number, incomparable: number, unchanged: number},
98
+ * disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
99
+ * projects: object, edges: object}, coverage: object}} input
100
+ * @returns {string}
101
+ */
102
+ export function formatTrajectoryReport({ trajectory, coverage }) {
103
+ const sections = [];
104
+
105
+ const observations = trajectory.observations;
106
+ sections.push(`trajectory ${trajectory.dir}`);
107
+ sections.push(
108
+ `${observations.count} observation${observations.count === 1 ? "" : "s"} ` +
109
+ `(${observations.basis}), ${trajectory.transitions.count} transition${
110
+ trajectory.transitions.count === 1 ? "" : "s"
111
+ }`,
112
+ );
113
+
114
+ if (!trajectory.available) {
115
+ // Named, not implied: a one-snapshot history cannot show movement, and
116
+ // every derived number below stays n/a rather than reading as a zero.
117
+ sections.push(
118
+ `✖ ${trajectory.unavailableReason}: a trajectory needs at least two observations — ` +
119
+ "derived values are unavailable, not zero",
120
+ );
121
+ }
122
+
123
+ const t = trajectory.transitions;
124
+ sections.push(
125
+ `signals architecture ${t.architecture} · policy ${t.policy} · provider ${t.provider} · ` +
126
+ `code drift ${t.codeDrift} · incomparable ${t.incomparable} · unchanged ${t.unchanged}`,
127
+ );
128
+ sections.push(`projects ${formatAxis(trajectory.projects, true)}`);
129
+ sections.push(`edges ${formatAxis(trajectory.edges, false)}`);
130
+
131
+ const d = trajectory.disclosures;
132
+ sections.push(
133
+ `disclosures policy incomparable ${d.policyOneSided} · provenance incomparable ` +
134
+ `${d.provenanceOneSided} · cross-repo ${d.crossRepo} · ` +
135
+ `dirty captures ${observations.dirtyProvenance} · with provenance ${observations.withProvenance}`,
136
+ );
137
+
138
+ for (const note of coverage.notes) {
139
+ sections.push(sanitize(note));
140
+ }
141
+
142
+ return sections.join("\n");
143
+ }
@@ -16,7 +16,11 @@
16
16
 
17
17
  /**
18
18
  * @param {{waivers: object[], covered: number, expired: number, stale: number,
19
- * suppressions: object[], suppressed: number}} result
19
+ * suppressions: object[], suppressed: number,
20
+ * unownedAcceptances?: {path: string, reason: string, covered: number}[]}} result
21
+ * `unownedAcceptances` is present exactly when the policy declares
22
+ * `coverage.unowned` (`../commands/waivers.mjs`) — the third surface, and
23
+ * the one the acceptances' reasons live on.
20
24
  * @returns {string}
21
25
  */
22
26
  export function formatWaiversReport({
@@ -26,10 +30,17 @@ export function formatWaiversReport({
26
30
  stale,
27
31
  suppressions,
28
32
  suppressed,
33
+ unownedAcceptances,
29
34
  }) {
30
35
  const sections = [];
36
+ const acceptances = unownedAcceptances ?? [];
31
37
 
32
- if (waivers.length === 0 && suppressions.length === 0) {
38
+ if (waivers.length === 0 && suppressions.length === 0 && acceptances.length === 0) {
39
+ // The all-empty claim may only be made when all THREE surfaces were
40
+ // measured and found empty — a declared `coverage.unowned` row is an
41
+ // acceptance on the table exactly as a suppression is, and this line
42
+ // reading "nothing is accepted" over one would be the module header's
43
+ // defect on a third table.
33
44
  return `no waivers — every boundary is enforced, nothing is being accepted temporarily or permanently`;
34
45
  }
35
46
 
@@ -96,5 +107,27 @@ export function formatWaiversReport({
96
107
  }
97
108
  }
98
109
 
110
+ if (acceptances.length > 0) {
111
+ // The coverage half of the table: each row accepts unowned FILES rather
112
+ // than a verdict — `check` still states the files as an accepted
113
+ // coverage gap, and this section is where their reasons live
114
+ // (`../commands/coverage-acceptance.mjs`). A row covering nothing is
115
+ // named the way a stale waiver is; `check` refuses it outright.
116
+ const totalCovered = acceptances.reduce((sum, row) => sum + row.covered, 0);
117
+ sections.push(
118
+ `${acceptances.length} coverage acceptance${acceptances.length === 1 ? "" : "s"} ` +
119
+ `(coverage.unowned) on the table — currently accepting ${totalCovered} unowned ` +
120
+ `file${totalCovered === 1 ? "" : "s"} as recorded coverage holes`,
121
+ );
122
+
123
+ for (const row of acceptances) {
124
+ const coverage =
125
+ row.covered === 0
126
+ ? "covers no unowned file right now — the files it accepted may be owned by a project now"
127
+ : `currently covers ${row.covered} unowned file${row.covered === 1 ? "" : "s"}`;
128
+ sections.push([`- ${row.path}: ${coverage}`, ` reason: ${row.reason}`].join("\n"));
129
+ }
130
+ }
131
+
99
132
  return sections.join("\n\n");
100
133
  }