@ecoma-io/archkeep 0.14.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.
@@ -6,7 +6,10 @@
6
6
  * Sections render only when they have content — introduced (with waived
7
7
  * annotations), resolved, unchanged (with the occurrences-reduced note where
8
8
  * one applies), unknown (with the reason each identity could not be stated),
9
- * and the unresolvable-import block and the summary line always states what
9
+ * the unresolvable-import block, and the custom-rules block (only when the
10
+ * delta computed one — `../commands/delta.mjs` keeps it absent for a
11
+ * workspace where neither side declares custom rules) — and the summary line
12
+ * always states what
10
13
  * was compared: base and head identity, record and project counts, and the
11
14
  * bucket totals. "No introduced violations" is a claim about a comparison the
12
15
  * reader can verify, never silence (`../../../../AGENTS.md`).
@@ -59,6 +62,71 @@ function unknownLines(entry) {
59
62
  return [` ? ${entry.reason}`];
60
63
  }
61
64
 
65
+ /** One classified custom-finding entry as its report lines. */
66
+ function customFindingLines(entry) {
67
+ const where = entry.project === null ? "" : ` in ${entry.project}`;
68
+ const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
69
+ const lines = [` ${entry.ruleId}${where} (${counts})`];
70
+ if (entry.message !== undefined) lines.push(` ${entry.message}`);
71
+ if (entry.note !== undefined) lines.push(` ${entry.note}`);
72
+ const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
73
+ for (const site of sites) {
74
+ // A custom finding states a position only when its rule stated one — a
75
+ // whole-workspace finding has no file, and no line is printed for it.
76
+ if (site.file !== undefined) lines.push(` at ${site.file}:${site.line}:${site.column}`);
77
+ }
78
+ return lines;
79
+ }
80
+
81
+ /** One unknown custom entry — the rule name plus the mandatory reason. */
82
+ function customUnknownLines(entry) {
83
+ return [` ? ${entry.rule}: ${entry.reason}`];
84
+ }
85
+
86
+ /**
87
+ * The custom-rules block, rendered only when the delta computed one — a
88
+ * workspace where neither side declares custom rules keeps the exact report
89
+ * it already had.
90
+ *
91
+ * @param {{judged: object[], skipped: object[], removed: string[],
92
+ * findings: object}} customRules
93
+ * @param {{customFindings: {introduced: number, resolved: number,
94
+ * unchanged: number, unknown: number}}} summary
95
+ * @returns {string[]}
96
+ */
97
+ function customRulesSections(customRules, summary) {
98
+ const { judged, skipped, removed, findings } = customRules;
99
+ const counts = summary.customFindings;
100
+ const lines = [
101
+ `custom rules (${judged.length} judged, ${skipped.length} skipped, ${removed.length} removed)`,
102
+ ];
103
+ lines.push(
104
+ ...section(
105
+ ` ⚠ ${counts.introduced} introduced custom finding${counts.introduced === 1 ? "" : "s"}`,
106
+ findings.introduced.map(customFindingLines),
107
+ ),
108
+ );
109
+ lines.push(
110
+ ...section(
111
+ ` ✔ ${counts.resolved} resolved custom finding${counts.resolved === 1 ? "" : "s"}`,
112
+ findings.resolved.map(customFindingLines),
113
+ ),
114
+ );
115
+ lines.push(
116
+ ...section(
117
+ ` = ${counts.unchanged} unchanged custom finding${counts.unchanged === 1 ? "" : "s"}`,
118
+ findings.unchanged.map(customFindingLines),
119
+ ),
120
+ );
121
+ lines.push(
122
+ ...section(
123
+ ` ? ${counts.unknown} unclassifiable custom item${counts.unknown === 1 ? "" : "s"}`,
124
+ findings.unknown.map(customUnknownLines),
125
+ ),
126
+ );
127
+ return lines;
128
+ }
129
+
62
130
  /**
63
131
  * One classification bucket as a section, or nothing when it is empty.
64
132
  *
@@ -162,6 +230,10 @@ export function formatDeltaReport({ delta, coverage }) {
162
230
  );
163
231
  }
164
232
 
233
+ if (delta.customRules !== undefined) {
234
+ sections.push(...customRulesSections(delta.customRules, summary));
235
+ }
236
+
165
237
  // The closing claim always states what was compared, so an empty delta is a
166
238
  // verifiable statement rather than silence.
167
239
  const compared =
@@ -178,6 +250,15 @@ export function formatDeltaReport({ delta, coverage }) {
178
250
  `waived — ${compared}`,
179
251
  );
180
252
  }
253
+ // The custom gate's own closing claim, so a delta whose only introduction is
254
+ // a custom finding does not end on a line reading clean.
255
+ if (delta.customRules !== undefined && summary.customFindings.introduced > 0) {
256
+ const count = summary.customFindings.introduced;
257
+ sections.push(
258
+ `⚠ ${count} introduced custom finding${count === 1 ? "" : "s"} — custom findings have ` +
259
+ `no waiver lane, every one gates`,
260
+ );
261
+ }
181
262
 
182
263
  return sections.join("\n");
183
264
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The terminal report for the `evolution` command: the selected revisions in
3
+ * history order, each transition classified, and what the whole record can
4
+ * and cannot say.
5
+ *
6
+ * Like `history-text.mjs`, counts end every section so a reader never decides
7
+ * whether an omission is content or silence — and the summary line names the
8
+ * range the record is a claim ABOUT, because "how the architecture evolved"
9
+ * without naming the compared revisions is not a reproducible claim.
10
+ *
11
+ * This module decides nothing. A formatter that filtered would be a rule
12
+ * wearing a formatter's name (`../README.md`); the transition formatters are
13
+ * shared with `history-text.mjs` (`./snapshot-text.mjs`) so both commands
14
+ * render one classification the same way.
15
+ */
16
+
17
+ import { formatChanges, sanitize, transitionKind } from "./snapshot-text.mjs";
18
+
19
+ /**
20
+ * The whole evolution report.
21
+ *
22
+ * @param {{result: {base: string, head: string,
23
+ * revisions: {commit: string, id: string}[],
24
+ * transitions: {from: string, to: string, architectureChanged: boolean,
25
+ * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
26
+ * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
27
+ * @returns {string}
28
+ */
29
+ export function formatEvolutionReport({ result, coverage }) {
30
+ const sections = [];
31
+
32
+ const transitionWord = result.transitions.length === 1 ? "transition" : "transitions";
33
+ const revisionWord = result.revisions.length === 1 ? "revision" : "revisions";
34
+ const inspected = `${coverage.imports} import${
35
+ coverage.imports === 1 ? "" : "s"
36
+ } in ${coverage.analyzedFiles} file${
37
+ coverage.analyzedFiles === 1 ? "" : "s"
38
+ } across ${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
39
+
40
+ sections.push(
41
+ `evolution ${sanitize(result.base.slice(0, 12))}..${sanitize(result.head.slice(0, 12))}`,
42
+ );
43
+ sections.push(
44
+ `${result.revisions.length} ${revisionWord}, ${result.transitions.length} ${transitionWord} (${inspected})`,
45
+ );
46
+
47
+ for (const [i, revision] of result.revisions.entries()) {
48
+ sections.push(`${i} ${sanitize(revision.commit)} ${revision.id.slice(0, 8)}`);
49
+ }
50
+
51
+ // The footer counts true architectural change only — the same discipline as
52
+ // `history-text.mjs`: a policy or provider transition is a change to how the
53
+ // record reads, not to the architecture itself.
54
+ let changed = 0;
55
+ for (const transition of result.transitions) {
56
+ if (transition.architectureChanged) changed += 1;
57
+ const kind = transitionKind(transition);
58
+ sections.push(`~ ${sanitize(transition.from)} → ${sanitize(transition.to)} (${kind})`);
59
+ if (transition.changes) {
60
+ for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
61
+ }
62
+ for (const note of transition.notes) {
63
+ sections.push(` ${note}`);
64
+ }
65
+ }
66
+
67
+ if (changed === 0) {
68
+ const anySignal = result.transitions.some(
69
+ (t) => t.policyChanged === true || t.providerChanged || t.codeDrift,
70
+ );
71
+ sections.push(
72
+ anySignal
73
+ ? "✔ no architectural change across the selected revisions (only policy, provider, or drift signals)"
74
+ : "✔ no change at all across the selected revisions",
75
+ );
76
+ } else {
77
+ sections.push(
78
+ `${changed} transition${changed === 1 ? "" : "s"} recorded an architectural change`,
79
+ );
80
+ }
81
+
82
+ return sections.join("\n");
83
+ }
@@ -7,122 +7,12 @@
7
7
  * actually holds. Counts end every section, so a reader is never left deciding
8
8
  * whether an omission is content or silence.
9
9
  *
10
- * This module decides nothing. A formatter that filtered would be a rule
11
- * wearing a formatter's name (`../README.md`).
10
+ * The per-transition formatters live in `./snapshot-text.mjs`, beside the
11
+ * ones `evolution-text.mjs` renders from — one home for the way a transition
12
+ * becomes prose, so the two commands cannot disagree about it.
12
13
  */
13
14
 
14
- /**
15
- * Neutralises control and terminal-escape sequences in a name or value before
16
- * it is printed, so a crafted project/tag/edge name cannot inject escape
17
- * sequences into a consumer's terminal (`SECURITY.md`). Real project names are
18
- * ordinary characters and pass through untouched; only C0 control characters
19
- * (which includes the ESC byte) and DEL become visible escapes.
20
- *
21
- * @param {string} text
22
- * @returns {string}
23
- */
24
- function sanitize(text) {
25
- // eslint-disable-next-line no-control-regex
26
- return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
27
- if (c === "\n") return "\\n";
28
- if (c === "\t") return "\\t";
29
- if (c === "\r") return "\\r";
30
- return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
31
- });
32
- }
33
-
34
- /**
35
- * One project as a line, same shape as `graph-text.mjs`.
36
- *
37
- * @param {{name: string, root: string, tags: string[]}} project
38
- * @returns {string}
39
- */
40
- function formatProject(project) {
41
- const tags =
42
- project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
43
- return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
44
- }
45
-
46
- /**
47
- * One edge as a line, same shape as `graph-text.mjs`.
48
- *
49
- * @param {{source: string, target: string, type: string}} edge
50
- * @returns {string}
51
- */
52
- function formatEdge(edge) {
53
- return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
54
- }
55
-
56
- /**
57
- * One metadata change as a line, same shape as `diff-text.mjs`.
58
- *
59
- * @param {{field: string, baseline: *, head: *}} change
60
- * @returns {string}
61
- */
62
- function formatChange(change) {
63
- const formatValue = (v) => {
64
- if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
65
- if (v === null || v === undefined) return "(none)";
66
- return sanitize(String(v));
67
- };
68
- return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
69
- }
70
-
71
- /**
72
- * How the architecture actually changed between two snapshots: the added and
73
- * removed projects and edges rendered as one line each. Changed projects
74
- * render their changed fields beneath the project line, like `diff`.
75
- *
76
- * @param {object} changes The `computeDiff` payload.
77
- * @returns {string[]}
78
- */
79
- function formatChanges(changes) {
80
- const lines = [];
81
- if (changes.addedProjects.length > 0) {
82
- const word = changes.addedProjects.length === 1 ? "project" : "projects";
83
- lines.push(`+ ${changes.addedProjects.length} added ${word}`);
84
- for (const project of changes.addedProjects) lines.push(formatProject(project));
85
- }
86
- if (changes.removedProjects.length > 0) {
87
- const word = changes.removedProjects.length === 1 ? "project" : "projects";
88
- lines.push(`- ${changes.removedProjects.length} removed ${word}`);
89
- for (const project of changes.removedProjects) lines.push(formatProject(project));
90
- }
91
- if (changes.changedProjects.length > 0) {
92
- const word = changes.changedProjects.length === 1 ? "project" : "projects";
93
- lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
94
- for (const project of changes.changedProjects) {
95
- lines.push(` ${project.name}`);
96
- for (const change of project.changes) lines.push(formatChange(change));
97
- }
98
- }
99
- if (changes.addedEdges.length > 0) {
100
- const word = changes.addedEdges.length === 1 ? "edge" : "edges";
101
- lines.push(`+ ${changes.addedEdges.length} added ${word}`);
102
- for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
103
- }
104
- if (changes.removedEdges.length > 0) {
105
- const word = changes.removedEdges.length === 1 ? "edge" : "edges";
106
- lines.push(`- ${changes.removedEdges.length} removed ${word}`);
107
- for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
108
- }
109
- return lines;
110
- }
111
-
112
- /**
113
- * Classifies one transition into the short "kind" a reader skims for.
114
- *
115
- * @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
116
- * providerChanged: boolean}} transition
117
- * @returns {string}
118
- */
119
- function transitionKind(transition) {
120
- if (transition.architectureChanged) return "architecture";
121
- if (transition.providerChanged) return "provider";
122
- if (transition.policyChanged === true) return "policy";
123
- if (transition.codeDrift) return "code drift";
124
- return "unchanged";
125
- }
15
+ import { formatChanges, transitionKind } from "./snapshot-text.mjs";
126
16
 
127
17
  /**
128
18
  * The whole history report.
@@ -956,6 +956,261 @@ export function buildSarifLog({
956
956
  };
957
957
  }
958
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
+
959
1214
  /**
960
1215
  * The SARIF log as the bytes to write — pretty-printed with a trailing newline,
961
1216
  * so a file that lands in a diff or a log stays readable.