@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,953 @@
1
+ /**
2
+ * SARIF 2.1.0 — the machine-readable half of the report, written for GitHub's
3
+ * `upload-sarif`. No schema validator is installed in this workspace; what
4
+ * `sarif.integration.test.mjs` pins is the subset of the 2.1.0 schema a
5
+ * rejected upload turns on. A file GitHub silently rejects is worse than no
6
+ * file at all: the job stays green, the annotations never appear, and nothing
7
+ * says why.
8
+ *
9
+ * Four fields carry the whole value and each is easy to get subtly wrong:
10
+ *
11
+ * - **`ruleId` is upstream's `messageId`, spelled exactly.** That is the same
12
+ * contract `../rules/messages.mjs` keeps for the text: two tools that both
13
+ * say "error" agree on nothing until they name the same rule, and a
14
+ * differential comparison against `@nx/enforce-module-boundaries` has nothing
15
+ * to compare otherwise. The rule catalogue is DERIVED from that module's
16
+ * message table, so a rule added upstream cannot be missing here.
17
+ * - **`artifactLocation.uri` is workspace-relative**, which is what GitHub
18
+ * resolves an annotation against. The analysis contract already produces
19
+ * workspace-relative paths, so this module only has to not break one — it
20
+ * percent-encodes per segment, because a path containing a space or a `#` is
21
+ * not a valid URI reference and the whole run is rejected for one file.
22
+ * - **`region` is 1-based** in both axes, matching the analysis records; SARIF
23
+ * agrees, so nothing is converted. `columnKind` is stated rather than left to
24
+ * the default because the analyzers made a real choice — columns count UTF-16
25
+ * code units (`../analysis/source-util.mjs`) — and a consumer that assumed
26
+ * code points would land in the wrong column on any line with an emoji.
27
+ * - **`level` is `error`** on every result. This report exists to block a
28
+ * merge; a warning would render as an annotation nobody has to act on.
29
+ *
30
+ * Analysis failures are NOT results. A file this tool could not parse is a
31
+ * place it has no verdict about, and filing it as a finding would put a
32
+ * boundary alert on code that may well be clean. They travel as
33
+ * `invocations[].toolExecutionNotifications`, which is SARIF's own slot for
34
+ * "the tool had trouble here", at `warning` — `executionSuccessful` stays true
35
+ * because the run did complete. go.work drift findings and dead tsconfig path
36
+ * aliases ARE results, under their own rule ids: they are verdicts the run
37
+ * fails on, not trouble it hit (`sarifGoWorkResult`,
38
+ * `sarifTsconfigPathsResult`). A `fail`-verdict fitness function follows the
39
+ * same rule — it is exactly as build-failing as a boundary violation
40
+ * (`verdictFor`'s `fitnessFail`) — while an `unknown`-verdict one (the run
41
+ * could not determine it) is trouble the tool hit, not a finding, so it rides
42
+ * a notification instead (`sarifFitnessResult`, `sarifFitnessNotification`).
43
+ * A declared custom rule (`../commands/custom-rules.mjs`) splits the same way,
44
+ * with one addition its own function argues: a rule that did NOT apply is a
45
+ * notification too, because "did not apply" and "applied and found nothing"
46
+ * are the two states an empty results array cannot tell apart.
47
+ *
48
+ * **The driver carries no `version`/`semanticVersion`, and that is now a gap
49
+ * rather than a decision.** It was written when this package was unversioned and
50
+ * unpublished, where any number would have been invented; the package has a real
51
+ * version and a registry entry today, so a consumer comparing two SARIF uploads
52
+ * has no way to tell which build produced which. Filling it in changes the
53
+ * reported output, which is the one kind of change this repository treats as
54
+ * breaking even when no API moved, so it lands as its own decision rather than
55
+ * as a side effect of a comment being corrected.
56
+ */
57
+ import { INTENT_MESSAGE_IDS, INTENT_MESSAGES } from "../architecture-intent/judge.mjs";
58
+ import { GO_WORK_MESSAGE_IDS, GO_WORK_MESSAGES } from "../go-work.mjs";
59
+ import { MESSAGE_IDS, MESSAGES } from "../rules/messages.mjs";
60
+ import { TSCONFIG_PATHS_MESSAGE_IDS, TSCONFIG_PATHS_MESSAGES } from "../tsconfig-paths.mjs";
61
+
62
+ import { formatConstraint } from "./text.mjs";
63
+
64
+ /** The schema every consumer of this file validates against. */
65
+ export const SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
66
+ export const SARIF_VERSION = "2.1.0";
67
+
68
+ /**
69
+ * The single rule id every failing fitness function's result shares — see
70
+ * `sarifRules()`'s own comment for why one id stands in for the whole
71
+ * open-ended, workspace-declared set.
72
+ */
73
+ export const FITNESS_FAILED_RULE_ID = "fitnessFunctionFailed";
74
+
75
+ /**
76
+ * A workspace-relative path as a URI reference: each segment percent-encoded,
77
+ * separators left alone.
78
+ *
79
+ * `encodeURIComponent` and not `encodeURI`: the latter leaves `#` and `?`
80
+ * unescaped, and a file named `notes#1.ts` would truncate the URI at the
81
+ * fragment. Unreserved characters — letters, digits, `-`, `_`, `.`, `~` — pass
82
+ * through untouched, so an ordinary path is byte-identical to its input.
83
+ *
84
+ * **This function encodes; it does not vet.** A path that is absolute or
85
+ * carries a `..` segment is one GitHub's code scanning drops without saying
86
+ * so, and the producer that could hand one over unbidden — a custom rule's
87
+ * finding, the only `uri` here whose value comes from wasm this engine did not
88
+ * write — is refused where its verdict is JUDGED rather than where it is
89
+ * rendered: `../custom-rules/host.mjs`'s `isWorkspaceRelative`, whose comment
90
+ * argues the layering. Every other caller's path is the tool's own fact:
91
+ * `git ls-files` output for a violation or an analysis failure, a graph node's
92
+ * project root for a declared edge, a literal for `go.work` and
93
+ * `architecture-intent.json`.
94
+ *
95
+ * There is deliberately no guard THROWING here, and the reason is the two
96
+ * remaining callers rather than a preference: the `tsConfig` option and a
97
+ * `--config` policy file are names the WORKSPACE chose, and either may
98
+ * legitimately point outside the tree — `../../cli.mjs`'s `resolvePolicy`
99
+ * reports the second as `relative(root, configPath)`, an escaping path a
100
+ * fitness result then locates against. A throw would turn those supported runs
101
+ * into a crash, and a silent rewrite would be worse than either, because it
102
+ * would point the reader at a different file with full confidence
103
+ * (`../../AGENTS.md`).
104
+ *
105
+ * @param {string} path Workspace-relative, `/`-separated.
106
+ * @returns {string}
107
+ */
108
+ export function toUriReference(path) {
109
+ return path.split("/").map(encodeURIComponent).join("/");
110
+ }
111
+
112
+ /**
113
+ * The rule catalogue: one descriptor per `messageId` a run can produce — the
114
+ * upstream boundary ids in `MESSAGE_IDS` order, then this package's own
115
+ * go.work drift ids, then its tsconfig paths hygiene ids, then its
116
+ * architecture-intent ids, so `ruleIndex` is an index into this exact array.
117
+ *
118
+ * Every id is listed, not only the ones that fired. A GitHub alert shows the
119
+ * rule's description beside the finding, and a catalogue that grew only as
120
+ * violations appeared would describe a rule on the run that reported it and
121
+ * leave it nameless on the next. Both halves are derived from their message
122
+ * tables rather than restated, so an id added to either cannot go missing.
123
+ *
124
+ * `shortDescription` is the template's first line and `fullDescription` the
125
+ * whole template, `{{placeholder}}`s intact — the placeholders are honest here:
126
+ * they show which facts the message interpolates, which is exactly what a rule
127
+ * description should say. The drift rules carry no `upstreamRule` property,
128
+ * because they have none: ESLint has no notion of go.work.
129
+ *
130
+ * A custom rule's catalogue rides at the END of the array, after every fixed
131
+ * id, because every `ruleIndex` above is computed as an offset from the fixed
132
+ * tables' lengths — a descriptor inserted anywhere else would renumber ids
133
+ * this file has already shipped. The entries are the ones each loaded rule
134
+ * DECLARED it can report (`../commands/custom-rules.mjs`), not the ones that
135
+ * fired: a rule described only on the run that reported it and nameless on the
136
+ * next is exactly what the paragraph above refuses for the built-in ids.
137
+ *
138
+ * @param {{ruleId: string, rule: string, findingId: string, message: string}[]} [customCatalogue]
139
+ * @returns {object[]}
140
+ */
141
+ export function sarifRules(customCatalogue = []) {
142
+ return [
143
+ ...MESSAGE_IDS.map((id) => ({
144
+ id,
145
+ name: id,
146
+ shortDescription: { text: MESSAGES[id].split("\n")[0] },
147
+ fullDescription: { text: MESSAGES[id] },
148
+ defaultConfiguration: { level: "error" },
149
+ properties: { upstreamRule: "@nx/enforce-module-boundaries" },
150
+ })),
151
+ ...GO_WORK_MESSAGE_IDS.map((id) => ({
152
+ id,
153
+ name: id,
154
+ shortDescription: { text: GO_WORK_MESSAGES[id].split("\n")[0] },
155
+ fullDescription: { text: GO_WORK_MESSAGES[id] },
156
+ defaultConfiguration: { level: "error" },
157
+ })),
158
+ ...TSCONFIG_PATHS_MESSAGE_IDS.map((id) => ({
159
+ id,
160
+ name: id,
161
+ shortDescription: { text: TSCONFIG_PATHS_MESSAGES[id].split("\n")[0] },
162
+ fullDescription: { text: TSCONFIG_PATHS_MESSAGES[id] },
163
+ defaultConfiguration: { level: "error" },
164
+ })),
165
+ ...INTENT_MESSAGE_IDS.map((id) => ({
166
+ id,
167
+ name: id,
168
+ shortDescription: { text: INTENT_MESSAGES[id].split("\n")[0] },
169
+ fullDescription: { text: INTENT_MESSAGES[id] },
170
+ defaultConfiguration: { level: "error" },
171
+ })),
172
+ // One entry, not a catalogue: a fitness function's `name` is
173
+ // workspace-declared and open-ended (`../governance/fitness-registry.mjs`),
174
+ // unlike the fixed message-id tables above, so it cannot be pre-catalogued
175
+ // the same way — the specific function is named in the result's message
176
+ // and `properties.name` instead (`sarifFitnessResult`).
177
+ {
178
+ id: FITNESS_FAILED_RULE_ID,
179
+ name: FITNESS_FAILED_RULE_ID,
180
+ shortDescription: { text: "A declared fitness function failed its verdict." },
181
+ fullDescription: {
182
+ text:
183
+ "A fitness function declared in the boundary policy's `fitness` list judged its " +
184
+ "matched projects and returned `fail` — the function name and reason are in the " +
185
+ "result message and the `name` property.",
186
+ },
187
+ defaultConfiguration: { level: "error" },
188
+ },
189
+ ...customCatalogue.map((entry) => ({
190
+ id: entry.ruleId,
191
+ name: entry.ruleId,
192
+ shortDescription: { text: entry.message.split("\n")[0] },
193
+ fullDescription: { text: entry.message },
194
+ defaultConfiguration: { level: "error" },
195
+ properties: { customRule: entry.rule, findingId: entry.findingId },
196
+ })),
197
+ ];
198
+ }
199
+
200
+ /**
201
+ * Where a custom rule's descriptors begin in `sarifRules()`'s array — every
202
+ * fixed id, then the one fitness entry. Derived from the same tables the
203
+ * offsets above are, so a message id added to any of them moves this with it.
204
+ */
205
+ const CUSTOM_RULE_INDEX_BASE =
206
+ MESSAGE_IDS.length +
207
+ GO_WORK_MESSAGE_IDS.length +
208
+ TSCONFIG_PATHS_MESSAGE_IDS.length +
209
+ INTENT_MESSAGE_IDS.length +
210
+ 1;
211
+
212
+ /**
213
+ * One custom-rule finding as a SARIF result.
214
+ *
215
+ * A result for the same reason a go.work drift finding is one: `check` fails
216
+ * the build on a `fail`-verdict custom rule (`verdictFor`'s `customRuleFail`),
217
+ * and a red job uploading an empty results array is the silent SARIF every
218
+ * finding kind above exists to avoid.
219
+ *
220
+ * The location block is OMITTED entirely when the finding names no
221
+ * `sourceFile` — a custom rule may judge the workspace as a whole, and SARIF
222
+ * has no way to say "somewhere in this repository" that is not a fabricated
223
+ * path. `region` follows the same rule one level down: present only when the
224
+ * rule stated a line, and carrying `startColumn` only when it stated one too,
225
+ * because a column with no line is a position SARIF cannot express.
226
+ *
227
+ * @param {{id: string, message: string, sourceFile?: string, line?: number,
228
+ * column?: number, project?: string}} finding A finding from
229
+ * `../commands/custom-rules.mjs`, its `id` already namespaced.
230
+ * @param {number} ruleIndex Its descriptor's index in `sarifRules()`.
231
+ * @returns {object}
232
+ */
233
+ export function sarifCustomRuleResult(finding, ruleIndex) {
234
+ const physicalLocation =
235
+ finding.sourceFile === undefined
236
+ ? null
237
+ : {
238
+ artifactLocation: { uri: toUriReference(finding.sourceFile) },
239
+ ...(finding.line === undefined
240
+ ? {}
241
+ : {
242
+ region: {
243
+ startLine: finding.line,
244
+ ...(finding.column === undefined ? {} : { startColumn: finding.column }),
245
+ },
246
+ }),
247
+ };
248
+ return {
249
+ ruleId: finding.id,
250
+ ruleIndex,
251
+ level: "error",
252
+ message: { text: finding.message },
253
+ // Both blocks below are present only when the rule stated the fact behind
254
+ // them: an empty property bag and a location naming no file each say
255
+ // nothing an absent one does not, the same "no fact, no claim" bargain
256
+ // every other optional field in this file keeps.
257
+ ...(finding.project === undefined ? {} : { properties: { project: finding.project } }),
258
+ ...(physicalLocation === null ? {} : { locations: [{ physicalLocation }] }),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * One custom rule that reached no verdict, as a tool-execution notification.
264
+ *
265
+ * Two verdicts ride this lane rather than one, and the second is the reason
266
+ * this function exists at all:
267
+ *
268
+ * - `unknown` — the rule loaded and could not judge. Trouble the tool hit, not
269
+ * a verdict it reached, exactly as `sarifFitnessNotification` treats an
270
+ * undetermined fitness function.
271
+ * - `not_applicable` — the rule did not apply, which today means a path-scoped
272
+ * run (`../commands/custom-rules.mjs`). Unlike a passing rule, which really
273
+ * did look and really did find nothing, a rule that did not apply looked at
274
+ * nothing — and with no notification a scoped run over a workspace declaring
275
+ * custom rules would upload a SARIF log byte-identical to one from a
276
+ * workspace that declares none. That indistinguishability is the defect the
277
+ * empty-result invariant names (`../../../../AGENTS.md`), so the log says so.
278
+ *
279
+ * A `pass` gets nothing, deliberately: "no results" is SARIF's own way of
280
+ * saying "looked, found nothing", which is exactly what a passing rule means.
281
+ *
282
+ * @param {{name: string, verdict: string, message: string}} decision
283
+ * @returns {object}
284
+ */
285
+ export function sarifCustomRuleNotification(decision) {
286
+ const posture =
287
+ decision.verdict === "not_applicable" ? "did not apply to this run" : "could not be judged";
288
+ return {
289
+ level: "warning",
290
+ message: { text: `Custom rule "${decision.name}" ${posture}: ${decision.message}` },
291
+ };
292
+ }
293
+
294
+ /**
295
+ * One violation as a SARIF result.
296
+ *
297
+ * The message is the rendered upstream text plus the one fact a GitHub
298
+ * annotation cannot show otherwise: which import, between which projects, under
299
+ * which constraint row. GitHub renders `message.text` and nothing else, so a
300
+ * developer reading the alert would otherwise see a rule about tags with no way
301
+ * to tell which line of their file it is about. The verbatim upstream text
302
+ * stays available, unmodified, in the property bag.
303
+ *
304
+ * @param {object} violation A `Violation` from `../rules/`.
305
+ * @returns {object}
306
+ */
307
+ export function sarifResult(violation) {
308
+ const detail =
309
+ `Import ${JSON.stringify(violation.specifier)} (${violation.kind}) ` +
310
+ `from ${violation.sourceProject ?? "(no project)"} ` +
311
+ `to ${violation.targetProject ?? "(unresolved)"}. ` +
312
+ `Constraint: ${formatConstraint(violation.constraint)}`;
313
+ return {
314
+ ruleId: violation.messageId,
315
+ ruleIndex: MESSAGE_IDS.indexOf(violation.messageId),
316
+ level: "error",
317
+ message: { text: `${violation.message}\n\n${detail}` },
318
+ locations: [
319
+ {
320
+ physicalLocation: {
321
+ artifactLocation: { uri: toUriReference(violation.sourceFile) },
322
+ region: { startLine: violation.line, startColumn: violation.column },
323
+ },
324
+ },
325
+ ],
326
+ properties: {
327
+ upstreamMessage: violation.message,
328
+ specifier: violation.specifier,
329
+ importKind: violation.kind,
330
+ sourceProject: violation.sourceProject,
331
+ targetProject: violation.targetProject,
332
+ ...(violation.constraint?.description
333
+ ? { ruleDescription: violation.constraint.description }
334
+ : {}),
335
+ ...(violation.constraint?.remediation
336
+ ? { remediation: violation.constraint.remediation }
337
+ : {}),
338
+ // A waived violation is still a result — the run still fails — but the
339
+ // properties say why it is present: an active waiver accepts it (with
340
+ // its expiry), or an expired one re-asserted it. Absent on a plain
341
+ // violation, so an unchanged tree produces unchanged SARIF.
342
+ ...(violation.waivedBy
343
+ ? {
344
+ accepted: true,
345
+ acceptedUntil: violation.waivedBy.expiresAt,
346
+ acceptedReason: violation.waivedBy.reason,
347
+ }
348
+ : {}),
349
+ ...(violation.evidence ? { evidence: violation.evidence } : {}),
350
+ },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * One go.work drift finding as a SARIF result.
356
+ *
357
+ * A result and not a notification, deliberately: drift is a verdict the run
358
+ * fails on, exactly like a violation, and a finding that only reached the exit
359
+ * code would leave a code-scanning consumer looking at a red job with an empty
360
+ * upload. A missing-use finding is about an entry that does not exist, so its
361
+ * location carries the artifact alone rather than a fabricated line 1 — the
362
+ * same reasoning as `sarifNotification` below.
363
+ *
364
+ * @param {object} finding A finding from `../go-work.mjs` `compareGoWork`.
365
+ * @returns {object}
366
+ */
367
+ export function sarifGoWorkResult(finding) {
368
+ const physicalLocation = { artifactLocation: { uri: toUriReference(finding.file) } };
369
+ if (finding.line !== null) {
370
+ physicalLocation.region = { startLine: finding.line, startColumn: finding.column };
371
+ }
372
+ return {
373
+ ruleId: finding.messageId,
374
+ ruleIndex: MESSAGE_IDS.length + GO_WORK_MESSAGE_IDS.indexOf(finding.messageId),
375
+ level: "error",
376
+ message: { text: finding.message },
377
+ locations: [{ physicalLocation }],
378
+ properties: {
379
+ directory: finding.directory,
380
+ project: finding.project,
381
+ },
382
+ };
383
+ }
384
+
385
+ /**
386
+ * One dead tsconfig path alias as a SARIF result.
387
+ *
388
+ * A result for the same reason a go.work drift finding is one: it is a verdict
389
+ * the run fails on. The finding is positionless by construction — the parsed
390
+ * compiler options carry no source positions, and under `extends` the alias
391
+ * may not be declared in the file the workspace names — so the location
392
+ * carries the artifact alone rather than a fabricated line 1, the reasoning
393
+ * `sarifNotification` states.
394
+ *
395
+ * @param {object} finding A finding from `../tsconfig-paths.mjs`.
396
+ * @returns {object}
397
+ */
398
+ export function sarifTsconfigPathsResult(finding) {
399
+ return {
400
+ ruleId: finding.messageId,
401
+ ruleIndex:
402
+ MESSAGE_IDS.length +
403
+ GO_WORK_MESSAGE_IDS.length +
404
+ TSCONFIG_PATHS_MESSAGE_IDS.indexOf(finding.messageId),
405
+ level: "error",
406
+ message: { text: finding.message },
407
+ locations: [{ physicalLocation: { artifactLocation: { uri: toUriReference(finding.file) } } }],
408
+ properties: {
409
+ alias: finding.alias,
410
+ targets: finding.targets,
411
+ },
412
+ };
413
+ }
414
+
415
+ /**
416
+ * One declared-edge violation as a SARIF result — an `implicit`-typed graph
417
+ * edge (Nx's and `archkeep.json`'s `implicitDependencies`, a `moon.yml`'s
418
+ * `dependsOn`) that crosses a `depConstraints` boundary with no import site
419
+ * behind it.
420
+ *
421
+ * `messageId` is one of the same three `depConstraints` ids `sarifResult`
422
+ * already catalogues (`onlyTagsConstraintViolation`,
423
+ * `notTagsConstraintViolation`, `projectWithoutTagsCannotHaveDependencies`) —
424
+ * `judgeEdge` (`../commands/edge-constraints.mjs`) reuses the identical
425
+ * tag-matching functions `evaluate()`'s import-site path does, so the rule IS
426
+ * the same rule and needs no second entry in `sarifRules()`; only the
427
+ * `ruleIndex` lookup is shared, via `MESSAGE_IDS` rather than a second table.
428
+ *
429
+ * Positionless by construction — a declaration has no import statement to
430
+ * point a `region` at — so the location carries the declaring manifest alone,
431
+ * the same convention `sarifIntentResult` uses for a graph-edge finding with
432
+ * no source site of its own.
433
+ *
434
+ * `finding.file` is that manifest, chosen per provider by `../../cli.mjs`'s
435
+ * `declaredEdgeManifest`, and it has to be a path the reader's checkout
436
+ * really contains: GitHub's code scanning drops a result whose `uri` names no
437
+ * such file, without saying so — the identical failure
438
+ * `../custom-rules/host.mjs`'s `isWorkspaceRelative` refuses for a wasm
439
+ * rule's own findings. A Moon workspace got `archkeep.json` here until that
440
+ * function learned the provider, which is a file a Moon tree is refused for
441
+ * carrying at all.
442
+ *
443
+ * @param {object} finding A finding from `../commands/edge-constraints.mjs`'s
444
+ * `declaredEdgeViolationsForCheck`, extended with `file` — workspace-relative.
445
+ * @returns {object}
446
+ */
447
+ export function sarifDeclaredEdgeResult(finding) {
448
+ return {
449
+ ruleId: finding.messageId,
450
+ ruleIndex: MESSAGE_IDS.indexOf(finding.messageId),
451
+ level: "error",
452
+ message: { text: finding.message },
453
+ locations: [{ physicalLocation: { artifactLocation: { uri: toUriReference(finding.file) } } }],
454
+ properties: {
455
+ source: finding.source,
456
+ target: finding.target,
457
+ },
458
+ };
459
+ }
460
+
461
+ /**
462
+ * One architecture-intent finding as a SARIF result.
463
+ *
464
+ * A result for the same reason a go.work drift finding is one: it is a verdict
465
+ * the run fails on. The finding is positionless by construction — intent
466
+ * judges graph edges, not source sites, and the violating dependency's origin
467
+ * line is not part of the record (`../architecture-intent/judge.mjs`) — so the
468
+ * location carries the intent file alone rather than a fabricated line 1, the
469
+ * reasoning `sarifNotification` states.
470
+ *
471
+ * @param {object} finding A finding from `../architecture-intent/judge.mjs`.
472
+ * @returns {object}
473
+ */
474
+ export function sarifIntentResult(finding) {
475
+ return {
476
+ ruleId: finding.rule,
477
+ ruleIndex:
478
+ MESSAGE_IDS.length +
479
+ GO_WORK_MESSAGE_IDS.length +
480
+ TSCONFIG_PATHS_MESSAGE_IDS.length +
481
+ INTENT_MESSAGE_IDS.indexOf(finding.rule),
482
+ level: "error",
483
+ message: { text: finding.message },
484
+ locations: [
485
+ {
486
+ physicalLocation: { artifactLocation: { uri: toUriReference("architecture-intent.json") } },
487
+ },
488
+ ],
489
+ properties: {
490
+ source: finding.source,
491
+ target: finding.target,
492
+ boundaryFrom: finding.boundaryFrom,
493
+ boundaryTo: finding.boundaryTo,
494
+ },
495
+ };
496
+ }
497
+
498
+ /**
499
+ * One `fail`-verdict fitness function as a SARIF result.
500
+ *
501
+ * A result for the same reason a go.work drift finding is one: `check` fails
502
+ * the build on it (`verdictFor`'s `fitnessFail`), and an empty results array
503
+ * on that run would be exactly the silent SARIF this function exists to
504
+ * close. Positionless by construction — a fitness verdict judges the graph,
505
+ * not a source site — so the location carries the boundary policy file that
506
+ * declared the function, when the run resolved one (it always does, in
507
+ * practice: a fitness verdict cannot exist without the `fitness` list that
508
+ * only a loaded policy carries), the same "artifact alone, never a fabricated
509
+ * line 1" convention `sarifIntentResult` and `sarifNotification` state.
510
+ *
511
+ * @param {{name: string, message: string}} decision A `fail` verdict record
512
+ * from `evaluateFitness` (`../governance/fitness-registry.mjs`).
513
+ * @param {string|null} [policySource] Workspace-relative path to the boundary
514
+ * policy file that declared the function, or `null`/absent when this run
515
+ * resolved none — defensive only.
516
+ * @returns {object}
517
+ */
518
+ export function sarifFitnessResult(decision, policySource = null) {
519
+ const result = {
520
+ ruleId: FITNESS_FAILED_RULE_ID,
521
+ ruleIndex:
522
+ MESSAGE_IDS.length +
523
+ GO_WORK_MESSAGE_IDS.length +
524
+ TSCONFIG_PATHS_MESSAGE_IDS.length +
525
+ INTENT_MESSAGE_IDS.length,
526
+ level: "error",
527
+ message: { text: `Fitness function "${decision.name}" failed: ${decision.message}` },
528
+ properties: { name: decision.name },
529
+ };
530
+ if (policySource !== null) {
531
+ result.locations = [
532
+ { physicalLocation: { artifactLocation: { uri: toUriReference(policySource) } } },
533
+ ];
534
+ }
535
+ return result;
536
+ }
537
+
538
+ /**
539
+ * One `unknown`-verdict fitness function as a tool-execution notification.
540
+ *
541
+ * Not a result: an `unknown` verdict means the run could not determine the
542
+ * function (`check` exits 3 on it, never 1 — `verdictFor`'s `fitnessUnknown`),
543
+ * which is trouble the tool hit rather than a verdict it reached, the same
544
+ * distinction `sarifNotification` draws for an unparseable file.
545
+ *
546
+ * @param {{name: string, message: string}} decision An `unknown` verdict
547
+ * record from `evaluateFitness`.
548
+ * @returns {object}
549
+ */
550
+ export function sarifFitnessNotification(decision) {
551
+ return {
552
+ level: "warning",
553
+ message: {
554
+ text: `Fitness function "${decision.name}" could not be determined: ${decision.message}`,
555
+ },
556
+ };
557
+ }
558
+
559
+ /**
560
+ * One analysis failure as a tool-execution notification.
561
+ *
562
+ * A failure with no position is about the file as a whole (`line`/`column`
563
+ * `null` in the contract), and SARIF's `region` has no way to say "somewhere in
564
+ * here" — so the location carries the artifact alone rather than a fabricated
565
+ * line 1, which would put a marker on code that has nothing to do with it.
566
+ *
567
+ * @param {object} failure An `AnalysisFailure`.
568
+ * @returns {object}
569
+ */
570
+ export function sarifNotification(failure) {
571
+ const physicalLocation = { artifactLocation: { uri: toUriReference(failure.sourceFile) } };
572
+ if (failure.line !== null) {
573
+ physicalLocation.region = { startLine: failure.line, startColumn: failure.column };
574
+ }
575
+ return {
576
+ level: "warning",
577
+ message: { text: failure.reason },
578
+ locations: [{ physicalLocation }],
579
+ };
580
+ }
581
+
582
+ /**
583
+ * One unresolved architecture-intent boundary as a tool-execution notification.
584
+ *
585
+ * Not a result, for the reason `sarifFitnessNotification` states: a boundary
586
+ * that matched no observed project is one this run could not verify (`check`
587
+ * exits 3 on it, never 1 — `verdictFor`'s `intentUnresolved` in
588
+ * `../../cli.mjs`), which is trouble the tool hit rather than a verdict it
589
+ * reached. Filing it as an error-level result would put a boundary alert on an
590
+ * edge nothing judged.
591
+ *
592
+ * Before this it reached no SARIF field at all: `buildSarifLog` read
593
+ * `intent.findings` alone, so a run that exited 3 because the intent could not
594
+ * be established uploaded a log byte-identical to a clean run's, while the text
595
+ * face printed the warning (`./text.mjs`'s `formatIntentSection`). That is the
596
+ * empty-result invariant (`../../../../AGENTS.md`) failing on the one surface a
597
+ * CI gate reads.
598
+ *
599
+ * @param {{boundary: string, issue: string}} entry One `unresolved` record —
600
+ * the boundary that could not be verified, and why.
601
+ * @returns {object}
602
+ */
603
+ export function sarifIntentNotification(entry) {
604
+ return {
605
+ level: "warning",
606
+ message: {
607
+ text: `architecture-intent.json reached no verdict on boundary "${entry.boundary}": ${entry.issue}`,
608
+ },
609
+ };
610
+ }
611
+
612
+ /**
613
+ * How many unowned-file paths a coverage-gap notification names before it
614
+ * says how many are left — the same bound, and the same argument, as
615
+ * `./text.mjs`'s `UNOWNED_SAMPLE_LIMIT`. Held separately rather than imported
616
+ * because the two faces choose their own presentation and a shared constant
617
+ * would make one face's readability decision binding on the other's.
618
+ */
619
+ const UNOWNED_SAMPLE_LIMIT = 10;
620
+
621
+ /**
622
+ * One degraded-coverage note as a tool-execution notification.
623
+ *
624
+ * A `warning` and never a result: the run reached its verdict on everything it
625
+ * could see, and a gap says an entire CLASS of edge sits outside what the
626
+ * workspace's other tools cover — no rule was crossed, so an error-level
627
+ * annotation would claim a judgment nothing made. Dropping it is still the
628
+ * silent direction: an Nx workspace whose polyglot manifests `nx affected` and
629
+ * `@nx/enforce-module-boundaries` cannot see looks exactly like one that has
630
+ * none, which is why the text face already says so (`./text.mjs`'s
631
+ * `formatCoverageGaps`) and why this face must not be the one that stays quiet.
632
+ *
633
+ * A gap of any other kind speaks in the words its own `kind` gives rather than
634
+ * borrowing the unregistered-plugin sentence: a note naming the wrong cause is
635
+ * worse than one that names only the kind, because a reader cannot tell it is
636
+ * wrong.
637
+ *
638
+ * @param {{kind: string, manifests?: string[], files?: string[],
639
+ * languages?: string[]}} gap One record from the run's `coverageGaps`
640
+ * (`../commands/check.mjs`).
641
+ * @returns {object}
642
+ */
643
+ export function sarifCoverageGapNotification(gap) {
644
+ const manifests = gap.manifests ?? [];
645
+ const found = manifests.length > 0 ? `: ${manifests.join(", ")}` : "";
646
+ if (gap.kind === "unregistered-plugin") {
647
+ return {
648
+ level: "warning",
649
+ message: {
650
+ text:
651
+ `nx.json does not register this plugin but ${manifests.length} polyglot ` +
652
+ `manifest${manifests.length === 1 ? "" : "s"} found under project roots — ` +
653
+ `nx affected and @nx/enforce-module-boundaries will not cover these edges${found}`,
654
+ },
655
+ };
656
+ }
657
+ // Tracked analyzable files no project owns. The count and the languages are
658
+ // the whole message: the paths live in the JSON envelope, and a SARIF
659
+ // notification listing hundreds of them would be a log nobody can read
660
+ // rather than a fact an uploader can act on. Bounded the same way the text
661
+ // face bounds its own sample (`./text.mjs`'s `formatUnownedFilesGap`), and
662
+ // for the same reason, with the remainder named rather than dropped.
663
+ if (gap.kind === "unowned-files") {
664
+ const files = gap.files ?? [];
665
+ const languages = gap.languages ?? [];
666
+ const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
667
+ const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
668
+ const remaining = files.length - shown.length;
669
+ const listed =
670
+ shown.length > 0
671
+ ? `: ${shown.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
672
+ : "";
673
+ return {
674
+ level: "warning",
675
+ message: {
676
+ text:
677
+ `${files.length} tracked analyzable file${files.length === 1 ? "" : "s"}${spans} ` +
678
+ `owned by no project — skipped, so no boundary verdict in this run covers ` +
679
+ `${files.length === 1 ? "it" : "them"}${listed}`,
680
+ },
681
+ };
682
+ }
683
+ return {
684
+ level: "warning",
685
+ message: {
686
+ text:
687
+ `Coverage gap "${gap.kind}" — part of this workspace is outside what the ` +
688
+ `run's other tools cover${found}`,
689
+ },
690
+ };
691
+ }
692
+
693
+ /**
694
+ * One unresolved `decisionRef` — a row citing an ADR, rule, or fitness record
695
+ * that does not exist — as a tool-execution notification.
696
+ *
697
+ * The parenthetical is the text face's own, verbatim (`./text.mjs`'s
698
+ * `formatConstraint`), so a reader grepping either face for a citation finds
699
+ * the same string. A `warning` and not a result either way: the row claims an
700
+ * authority this run could not establish, which is not a boundary anyone
701
+ * crossed.
702
+ *
703
+ * The set carries both kinds of row, and the exit code separates them: an
704
+ * intent row's unresolved citation folds into the no-verdict lane
705
+ * (`verdictFor`'s `intentUnresolvedDecisionRefs` in `../../cli.mjs`), while a
706
+ * `depConstraints` row's changes no exit code at all. That second kind is the
707
+ * one this notification is load-bearing for — nothing else on this surface
708
+ * says the authority a row cites does not exist, so without it the fact
709
+ * reaches every face but the machine-readable one.
710
+ *
711
+ * @param {string} decisionRef The cited value, exactly as the row spelled it.
712
+ * @returns {object}
713
+ */
714
+ export function sarifDecisionRefNotification(decisionRef) {
715
+ return {
716
+ level: "warning",
717
+ message: {
718
+ text: `decisionRef [${decisionRef}] (UNRESOLVED — no matching ADR, rule, or fitness record)`,
719
+ },
720
+ };
721
+ }
722
+
723
+ /**
724
+ * The whole SARIF log.
725
+ *
726
+ * `policy` — which law this run enforced — rides `runs[0].properties`, SARIF's
727
+ * own generic property-bag extension point (the 2.1.0 schema's `propertyBag`,
728
+ * the same mechanism `sarifResult`'s own `properties` already use for
729
+ * per-finding metadata upstream SARIF has no field for). Before this, neither
730
+ * a clean run nor a violating one carried anything at all naming the config,
731
+ * profile, or fingerprint that produced it — a code-scanning consumer had no
732
+ * way to tell a strict run from one under a weaker policy (P1-01). Absent
733
+ * (never a `null`-valued key) when no caller supplied one, so a SARIF log
734
+ * built by anything other than `check` — nothing does today — stays
735
+ * byte-identical to before this field existed.
736
+ *
737
+ * `fitness` — the per-function verdict records `check` already computes
738
+ * (`../governance/fitness-registry.mjs`'s `evaluateFitness`) — was the one
739
+ * finding kind this function never rendered at all: `check --format sarif`
740
+ * exits 1 on a `fail`-verdict fitness function (`verdictFor`'s `fitnessFail`)
741
+ * while this function produced zero results and zero notifications for it, so
742
+ * a red CI job uploaded an empty SARIF log. Each `fail` decision becomes a
743
+ * result (`sarifFitnessResult`) and each `unknown` one a notification
744
+ * (`sarifFitnessNotification`), the same fail/could-not-determine split every
745
+ * other finding kind above already keeps. `fitnessOverall` carries no
746
+ * information this function needs beyond what each decision's own `verdict`
747
+ * already states, so it is not read here — `formatReport` (text) is the
748
+ * consumer that needs the aggregate label.
749
+ *
750
+ * `customRules` — the per-rule verdict records and the finding catalogue
751
+ * `../commands/custom-rules.mjs` produces — rides the same fail/could-not-tell
752
+ * split: each `fail` decision's findings become results resolving to their own
753
+ * descriptor, and each `unknown` or `not_applicable` decision becomes a
754
+ * notification (`sarifCustomRuleNotification` argues why the second one is
755
+ * there). A finding on a decision that is not `fail` produces no result: the
756
+ * rule reached no failing verdict, so an error-level annotation would claim a
757
+ * judgment it did not make, and the notification names the rule either way.
758
+ *
759
+ * Three facts the text face already prints reach this one as notifications,
760
+ * each of them a way a red run used to upload a log a clean run could have
761
+ * produced:
762
+ *
763
+ * - `intent.unresolved` — one per boundary the declared intent could not be
764
+ * verified against (`sarifIntentNotification`). `intent.verdict` is read
765
+ * only as the backstop below it: a `no-verdict` naming no boundary at all
766
+ * still speaks, because the exit code it produces has to be legible here
767
+ * too. This function read `intent.findings` and nothing else before, so a
768
+ * `check` exiting 3 on an intent it could not establish uploaded SARIF
769
+ * byte-identical to a clean run's.
770
+ * - `coverageGaps` — coverage this run knows it did not provide: the polyglot
771
+ * edges nothing in the workspace covers, and the tracked analyzable files no
772
+ * project owns (`sarifCoverageGapNotification`).
773
+ * - `unresolvedDecisionRefs` — every citation no ADR, rule, or fitness record
774
+ * answers (`sarifDecisionRefNotification`), sorted, which is the order
775
+ * `../../cli.mjs`'s JSON envelope lists the same set in: two faces of one
776
+ * run must not disagree about which citations failed to resolve.
777
+ *
778
+ * `notes` is deliberately not read. It is a mixed bag — which `boundaryConfig`
779
+ * entry bound the table (`../eslint-config.mjs`'s `extractBoundaryRule`), an
780
+ * allowed intent row with no statement, and the counts of imports left
781
+ * unconstrained — and none of it is a verdict or a subject that has one
782
+ * pending. Its coverage half belongs beside the run's other "what was
783
+ * inspected" numbers (`analyzed`, `imports`, `projects`), which this face
784
+ * carries none of either; filing it as a `warning` notification instead would
785
+ * report trouble on a run that hit none. Carrying them all in
786
+ * `runs[0].properties` is the shape that would fit, and it changes what an
787
+ * unchanged workspace reports, so it is its own decision rather than a side
788
+ * effect of this one.
789
+ *
790
+ * @param {{violations: object[], failures: object[],
791
+ * goWork?: {findings: object[], moduleProjects?: number}|null,
792
+ * tsconfigPaths?: {findings: object[], aliases?: number, unjudged?: number}|null,
793
+ * declaredEdges?: {findings: object[], judged?: number}|null,
794
+ * intent?: {verdict?: string, findings: object[],
795
+ * unresolved?: {boundary: string, issue: string}[]}|null,
796
+ * fitness?: {name: string, message: string, verdict: string}[],
797
+ * fitnessOverall?: {verdict: string},
798
+ * customRules?: {decisions: object[], catalogue: {ruleId: string, rule: string,
799
+ * findingId: string, message: string}[]}|null,
800
+ * coverageGaps?: {kind: string, manifests?: string[]}[],
801
+ * unresolvedDecisionRefs?: Iterable<string>|null,
802
+ * policy?: {profile: string|null, source: string, fingerprint: string}|null}} run
803
+ * @returns {object} A SARIF 2.1.0 log, ready to `JSON.stringify`.
804
+ */
805
+ export function buildSarifLog({
806
+ violations,
807
+ failures,
808
+ goWork,
809
+ tsconfigPaths,
810
+ declaredEdges,
811
+ intent,
812
+ fitness,
813
+ customRules,
814
+ coverageGaps = [],
815
+ // The `Set` `../../cli.mjs`'s `check` builds, taken as any iterable of
816
+ // strings so a caller holding the JSON envelope's array is not a caller this
817
+ // face silently ignores.
818
+ unresolvedDecisionRefs = null,
819
+ policy = null,
820
+ }) {
821
+ const fitnessDecisions = fitness ?? [];
822
+ const fitnessFailed = fitnessDecisions.filter((decision) => decision.verdict === "fail");
823
+ const fitnessUnresolved = fitnessDecisions.filter((decision) => decision.verdict === "unknown");
824
+ const customCatalogue = customRules?.catalogue ?? [];
825
+ const customDecisions = customRules?.decisions ?? [];
826
+ // The descriptor index a finding's namespaced id resolves to, so a result
827
+ // and its `reportingDescriptor` are looked up from ONE array rather than
828
+ // from two orderings that could drift apart.
829
+ const customRuleIndex = new Map(
830
+ customCatalogue.map((entry, index) => [entry.ruleId, CUSTOM_RULE_INDEX_BASE + index]),
831
+ );
832
+ const customResults = customDecisions
833
+ .filter((decision) => decision.verdict === "fail")
834
+ .flatMap((decision) =>
835
+ (decision.findings ?? []).map((finding) =>
836
+ // `-1` cannot arrive here: the host refuses a verdict naming a finding
837
+ // id its own catalogue does not declare (`../custom-rules/host.mjs`'s
838
+ // `findingViolation`), which is where "a finding SARIF drops" is
839
+ // stopped. `?? -1` is what makes a regression in that guarantee a
840
+ // visibly broken index rather than an `undefined` GitHub ignores.
841
+ sarifCustomRuleResult(finding, customRuleIndex.get(finding.id) ?? -1),
842
+ ),
843
+ );
844
+ const customNotifications = customDecisions.filter(
845
+ (decision) => decision.verdict === "unknown" || decision.verdict === "not_applicable",
846
+ );
847
+ const intentUnresolved = intent?.unresolved ?? [];
848
+ // `verdict` is read only as the fallback below: every no-verdict
849
+ // `../../cli.mjs` builds carries the boundaries in `unresolved`, so the
850
+ // second clause fires only for an intent that lost that detail on the way
851
+ // here — and an exit-3 intent naming no boundary must still not upload the
852
+ // log a clean run would have.
853
+ const intentNotifications =
854
+ intentUnresolved.length > 0
855
+ ? intentUnresolved.map(sarifIntentNotification)
856
+ : intent?.verdict === "no-verdict"
857
+ ? [
858
+ {
859
+ level: "warning",
860
+ message: {
861
+ text:
862
+ `architecture-intent.json reached no verdict and named no boundary — ` +
863
+ `the intent could not be verified, and the run fails.`,
864
+ },
865
+ },
866
+ ]
867
+ : [];
868
+ // Sorted, matching the JSON envelope's list of the same set
869
+ // (`../../cli.mjs`'s `check`), so the two faces of one run cannot be read as
870
+ // disagreeing about which citations resolved.
871
+ const decisionRefNotifications = [...(unresolvedDecisionRefs ?? [])]
872
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
873
+ .map(sarifDecisionRefNotification);
874
+ const waived = violations.filter((violation) => violation.waivedBy);
875
+ // A waived count rides as a notification so a consumer scanning for "did
876
+ // this run accept anything" finds it without reading every result's
877
+ // properties. The results themselves are unchanged (still error-level: an
878
+ // accepted violation is still a violation); this is the count the mission
879
+ // calls out — additive, and never a `!` that would read as a new error.
880
+ const waiverNote =
881
+ waived.length > 0
882
+ ? [
883
+ {
884
+ level: "warning",
885
+ message: {
886
+ text:
887
+ `${waived.length} boundary violation${waived.length === 1 ? "" : "s"} ` +
888
+ `accepted by waiver — the run stays non-zero, and each accepted ` +
889
+ `violation re-asserts the moment its waiver expires (see result properties ` +
890
+ `"acceptedUntil" on the accepted results).`,
891
+ },
892
+ },
893
+ ]
894
+ : [];
895
+ return {
896
+ $schema: SARIF_SCHEMA,
897
+ version: SARIF_VERSION,
898
+ runs: [
899
+ {
900
+ tool: { driver: { name: "archkeep", rules: sarifRules(customCatalogue) } },
901
+ columnKind: "utf16CodeUnits",
902
+ ...(policy == null ? {} : { properties: { policy } }),
903
+ results: [
904
+ ...violations.map(sarifResult),
905
+ ...(goWork?.findings ?? []).map(sarifGoWorkResult),
906
+ ...(tsconfigPaths?.findings ?? []).map(sarifTsconfigPathsResult),
907
+ ...(declaredEdges?.findings ?? []).map(sarifDeclaredEdgeResult),
908
+ ...(intent?.findings ?? []).map(sarifIntentResult),
909
+ ...fitnessFailed.map((decision) => sarifFitnessResult(decision, policy?.source ?? null)),
910
+ ...customResults,
911
+ ],
912
+ invocations: [
913
+ {
914
+ // True even on a red run: the tool did its job, and the findings
915
+ // are results rather than errors. Reporting false here makes GitHub
916
+ // treat the upload as a broken analysis and drop the annotations.
917
+ executionSuccessful: true,
918
+ toolExecutionNotifications: [
919
+ ...failures.map(sarifNotification),
920
+ ...decisionRefNotifications,
921
+ ...intentNotifications,
922
+ ...fitnessUnresolved.map(sarifFitnessNotification),
923
+ ...customNotifications.map(sarifCustomRuleNotification),
924
+ ...coverageGaps.map(sarifCoverageGapNotification),
925
+ ...waiverNote,
926
+ ],
927
+ },
928
+ ],
929
+ },
930
+ ],
931
+ };
932
+ }
933
+
934
+ /**
935
+ * The SARIF log as the bytes to write — pretty-printed with a trailing newline,
936
+ * so a file that lands in a diff or a log stays readable.
937
+ *
938
+ * @param {{violations: object[], failures: object[],
939
+ * goWork?: {findings: object[], moduleProjects?: number}|null,
940
+ * tsconfigPaths?: {findings: object[], aliases?: number, unjudged?: number}|null,
941
+ * intent?: {verdict?: string, findings: object[],
942
+ * unresolved?: {boundary: string, issue: string}[]}|null,
943
+ * fitness?: {name: string, message: string, verdict: string}[],
944
+ * fitnessOverall?: {verdict: string},
945
+ * customRules?: {decisions: object[], catalogue: object[]}|null,
946
+ * coverageGaps?: {kind: string, manifests?: string[]}[],
947
+ * unresolvedDecisionRefs?: Iterable<string>|null,
948
+ * policy?: {profile: string|null, source: string, fingerprint: string}|null}} run
949
+ * @returns {string}
950
+ */
951
+ export function formatSarif(run) {
952
+ return `${JSON.stringify(buildSarifLog(run), null, 2)}\n`;
953
+ }