@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|