sarif-to-comment 0.2.0 → 0.2.1

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 (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +23 -8
  3. package/dist/artifact-files.cjs +273 -0
  4. package/dist/cli.cjs +1062 -0
  5. package/dist/github.cjs +1181 -0
  6. package/dist/index.cjs +39 -0
  7. package/dist/placement.cjs +649 -0
  8. package/dist/prepare-review.cjs +1623 -0
  9. package/dist/publication.cjs +1055 -0
  10. package/dist/publish-sarif-review.cjs +560 -0
  11. package/dist/replacements.cjs +459 -0
  12. package/dist/sarif-authoring.cjs +313 -0
  13. package/dist/sarif-common.cjs +657 -0
  14. package/dist/sarif-inspection.cjs +834 -0
  15. package/dist/sarif-to-comment.cjs +37 -0
  16. package/dist/sarif-to-comment.d.ts +1018 -0
  17. package/dist/staged-changes.cjs +1140 -0
  18. package/dist/staged-git.cjs +391 -0
  19. package/docs/api/sarif-to-comment.iinspectionrun.md +2 -2
  20. package/docs/api/sarif-to-comment.iinspectionrun.source.md +4 -1
  21. package/docs/api/sarif-to-comment.iinspectionrun.tool.md +4 -1
  22. package/docs/api/sarif-to-comment.isarifinspection.log.md +3 -1
  23. package/docs/api/sarif-to-comment.isarifinspection.md +1 -1
  24. package/docs/api/sarif-to-comment.isarifinspection.summary.md +7 -7
  25. package/docs/api/sarif-to-comment.md +1 -1
  26. package/package.json +30 -17
  27. package/bin/sarif-to-comment.cjs +0 -29
  28. package/src/artifact-files.cjs +0 -255
  29. package/src/cli.cjs +0 -927
  30. package/src/github.cjs +0 -1092
  31. package/src/index.cjs +0 -532
  32. package/src/placement.cjs +0 -586
  33. package/src/prepare-review.cjs +0 -1547
  34. package/src/publication.cjs +0 -994
  35. package/src/replacements.cjs +0 -463
  36. package/src/sarif-authoring.cjs +0 -230
  37. package/src/sarif-common.cjs +0 -589
  38. package/src/sarif-inspection.cjs +0 -677
  39. package/src/staged-changes.cjs +0 -1026
  40. package/src/staged-git.cjs +0 -367
  41. package/types/index.d.ts +0 -1023
package/dist/cli.cjs ADDED
@@ -0,0 +1,1062 @@
1
+ 'use strict';
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.USAGE = void 0;
4
+ exports.main = main;
5
+ /**
6
+ * The sarif-to-comment command-line interface (src/sarif-to-comment.cts runs
7
+ * `main`). It is a file-oriented transport over the public library: every
8
+ * SARIF, Git and GitHub interpretation is the library's (src/index.cts), and
9
+ * this module adds only argument handling, file I/O (src/artifact-files.cts),
10
+ * credential selection, output formatting and exit statuses.
11
+ *
12
+ * Commands (contract: docs/second-milestone-contract-proposal.md §3, §5, §6):
13
+ * init create a SARIF file createSarifDocument
14
+ * add-comment add one finding, in place addSarifComment
15
+ * inspect read-only view of a SARIF file inspectSarif
16
+ * add-staged-changes add staged Git changes to a copy addStagedChangesToSarif
17
+ * publish create the GitHub draft review publishSarifReview
18
+ * A first argument beginning with "-" (or no argument) is the original
19
+ * flag-only publisher, which keeps its exact behavior, output, credentials and
20
+ * exit statuses. Anything else is a usage error.
21
+ *
22
+ * Output format (`--format human|json`, default human, never inferred from a
23
+ * terminal) is resolved before anything else. An invalid, missing or repeated
24
+ * `--format` is a human usage error on stderr with nothing on stdout. Once
25
+ * JSON is selected, every handled outcome — including help, usage errors and
26
+ * operational errors — is exactly one JSON document on stdout and nothing is
27
+ * written to stderr. The envelope is `{ command, status, ... }` where
28
+ * `command` is null only when no command was identified. Human mode writes
29
+ * outcomes to stdout and usage/operational errors to stderr.
30
+ *
31
+ * Exit statuses: 0 success or help; 1 usage error or operational error; 2 the
32
+ * content was refused (`invalid` / `failed`). `publish` keeps the publisher's
33
+ * statuses: 0 published, 2 blocked, 3 uncertain, 1 otherwise.
34
+ *
35
+ * Receipts name only files actually written, archived, or deliberately not
36
+ * written (`written: false`). The GitHub token (GH_TOKEN, else GITHUB_TOKEN)
37
+ * is used only by `publish` and is redacted from every output in both modes.
38
+ */
39
+ const fs = require("node:fs");
40
+ const path = require("node:path");
41
+ const files = require("./artifact-files.cjs");
42
+ const publish_sarif_review_cjs_1 = require("./publish-sarif-review.cjs");
43
+ const sarif_authoring_cjs_1 = require("./sarif-authoring.cjs");
44
+ const sarif_common_cjs_1 = require("./sarif-common.cjs");
45
+ const sarif_inspection_cjs_1 = require("./sarif-inspection.cjs");
46
+ const staged_changes_cjs_1 = require("./staged-changes.cjs");
47
+ const md = String.raw;
48
+ /** The commands, in workflow order. */
49
+ const COMMANDS = ['init', 'add-comment', 'inspect', 'add-staged-changes', 'publish'];
50
+ /** Exit statuses for non-publication outcomes (§5). */
51
+ const EXIT = Object.freeze({ ok: 0, usage: 1, error: 1, refused: 2 });
52
+ /** Exit statuses for publication, unchanged from the flag-only publisher. */
53
+ const PUBLISH_EXIT = Object.freeze({
54
+ published: 0,
55
+ blocked: 2,
56
+ uncertain: 3,
57
+ rejected: 1,
58
+ });
59
+ const COMMIT_FLAG_PATTERN = /^[0-9a-f]{40}$/;
60
+ const REPO_FLAG_PATTERN = /^([^/\s]+)\/([^/\s]+)$/;
61
+ // ---------------------------------------------------------------------------
62
+ // Usage text
63
+ // ---------------------------------------------------------------------------
64
+ const PUBLISH_OPTIONS = md ` --sarif FILE SARIF 2.1.0 JSON file to publish.
65
+ --repo OWNER/REPO Repository of the pull request.
66
+ --pull N Pull request number.
67
+ --commit FULLSHA Full 40-character commit the review is about.
68
+ --state ABSOLUTE_FILE Durable publication state. Retry with the same
69
+ file; never delete it after an uncertain
70
+ result. A new file starts a separate review.
71
+ --source-root ABSOLUTE_FILE_URI
72
+ Repository root in the SARIF producer's file
73
+ system (file:///.../ ending in "/").
74
+ --old-source-commit FULLSHA Candidate commit for the diff's old side, used
75
+ only when GitHub's comparison cannot establish
76
+ it; verified against the pull request's patches.
77
+ --ignore-approval-hold Publish despite an approval hold (bypasses only
78
+ the hold, never validation).
79
+ `;
80
+ const CREDENTIALS = md `Credentials (publish only):
81
+ GH_TOKEN, or else GITHUB_TOKEN: a GitHub personal access token or user token.
82
+ GitHub App installation tokens (including the automatic Actions token) are
83
+ not supported. There is no token flag.
84
+ `;
85
+ const FORMAT_OPTION = md ` --format human|json Output format (default human). JSON prints one
86
+ document on stdout for every outcome.
87
+ `;
88
+ /** Help text: the top-level usage and each command's. */
89
+ const USAGE = {
90
+ top: md `sarif-to-comment — author, inspect and publish SARIF as one GitHub draft review
91
+
92
+ Usage:
93
+ sarif-to-comment init --output FILE [options]
94
+ sarif-to-comment add-comment --sarif FILE --file PATH --line N (--message TEXT | --message-file FILE|-) [options]
95
+ sarif-to-comment inspect --sarif FILE [options]
96
+ sarif-to-comment add-staged-changes --sarif IN --output OUT --worktree DIR --repo OWNER/REPO --commit FULLSHA [options]
97
+ sarif-to-comment publish --sarif FILE --repo OWNER/REPO --pull N --commit FULLSHA --state ABSOLUTE_FILE [options]
98
+ sarif-to-comment --sarif FILE --repo OWNER/REPO --pull N --commit FULLSHA
99
+ --state ABSOLUTE_FILE [--source-root ABSOLUTE_FILE_URI]
100
+ [--old-source-commit FULLSHA] [--ignore-approval-hold]
101
+ sarif-to-comment [COMMAND] --help
102
+
103
+ Commands:
104
+ init Create a SARIF document for your own findings.
105
+ add-comment Add one finding on a line or line range to a SARIF file.
106
+ inspect Show the findings, locations and fixes in a SARIF file.
107
+ add-staged-changes Add proposed changes from the Git index to a SARIF document.
108
+ publish Publish a SARIF file as one GitHub draft pull request review.
109
+ Every command reads and writes ordinary SARIF files; SARIF from any producer
110
+ can be inspected, extended and published without init.
111
+
112
+ Publishing without a command (the original form) takes the publish options:
113
+ ${PUBLISH_OPTIONS}${FORMAT_OPTION} --help Show help. Needs no token, makes no request.
114
+
115
+ ${CREDENTIALS}
116
+ Exit status:
117
+ 0 success; published (or already published)
118
+ 2 refused content: blocked (nothing was published), or invalid/failed input
119
+ 3 uncertain: delivery could not be confirmed; retry with the same --state
120
+ 1 usage error, unreadable file, refused request, or operational failure
121
+ `,
122
+ init: md `sarif-to-comment init — create a SARIF document for your own findings
123
+
124
+ Usage:
125
+ sarif-to-comment init --output FILE [--tool-name NAME [--tool-version V]]
126
+ [--repo OWNER/REPO --commit FULLSHA] [--format human|json]
127
+
128
+ Options:
129
+ --output FILE New SARIF file; an existing file is refused.
130
+ --tool-name NAME Who the findings come from, for example
131
+ "Review agent" (default: sarif-to-comment).
132
+ --tool-version V Version of that tool (only with --tool-name).
133
+ --repo OWNER/REPO Bind the run to this repository ...
134
+ --commit FULLSHA ... at this full 40-character reviewed commit.
135
+ Give both or neither.
136
+ ${FORMAT_OPTION}
137
+ Exit status: 0 created; 1 usage error or the file could not be created.
138
+ `,
139
+ 'add-comment': md `sarif-to-comment add-comment — add one finding to a SARIF file
140
+
141
+ Usage:
142
+ sarif-to-comment add-comment --sarif FILE --file PATH --line N [--end-line M]
143
+ (--message TEXT | --message-file FILE|-) [--markdown]
144
+ [--rule-id ID] [--level none|note|warning|error]
145
+ [--run N | --new-run-tool NAME [--new-run-tool-version V]
146
+ [--repo OWNER/REPO --commit FULLSHA]]
147
+ [--format human|json]
148
+
149
+ The SARIF file is updated in place (atomically). Line numbers are one-based and
150
+ refer to the reviewed revision of the file (or to the proposed content of a
151
+ file that the reviewed revision does not have).
152
+
153
+ Options:
154
+ --sarif FILE SARIF file to update.
155
+ --file PATH Repository-relative path the finding is about.
156
+ --line N First line of the finding.
157
+ --end-line M Last line (inclusive); default: --line.
158
+ --message TEXT The finding's full text.
159
+ --message-file FILE|- Read the text from a UTF-8 file, or "-" for stdin.
160
+ --markdown The text is Markdown.
161
+ --rule-id ID Rule identifier to record.
162
+ --level LEVEL none, note, warning or error (omitted otherwise).
163
+ --run N Add to existing run N (needed when there are several).
164
+ --new-run-tool NAME Add to a new run attributed to NAME, so another
165
+ tool is never credited with your finding.
166
+ --new-run-tool-version V Version of that tool.
167
+ --repo OWNER/REPO Bind the new run to this repository ...
168
+ --commit FULLSHA ... at this reviewed commit. Give both or neither.
169
+ ${FORMAT_OPTION}
170
+ While it runs, the command owns FILE through a marker file
171
+ ".<name>.sarif-to-comment-lock" beside it; another command's marker is never
172
+ taken over.
173
+
174
+ Exit status: 0 added; 2 the SARIF file is not valid SARIF; 1 usage error or
175
+ the file could not be read or replaced.
176
+ `,
177
+ inspect: md `sarif-to-comment inspect — show the findings and fixes in a SARIF file
178
+
179
+ Usage:
180
+ sarif-to-comment inspect --sarif FILE [--preview-lines N|all] [--preview-chars N|all]
181
+ [--source-root ABSOLUTE_FILE_URI] [--format human|json]
182
+
183
+ Shows every finding with its full text, locations and fixes. Only fix previews
184
+ are shortened, and visibly so. The file is not changed and nothing is contacted.
185
+ Inspection is not a check that the file can be published.
186
+
187
+ Options:
188
+ --sarif FILE SARIF file to inspect.
189
+ --preview-lines N|all Lines shown per fix preview (default 20).
190
+ --preview-chars N|all Characters shown per fix preview (default 2000).
191
+ --source-root ABSOLUTE_FILE_URI
192
+ Repository root in the SARIF producer's file
193
+ system (file:///.../ ending in "/").
194
+ ${FORMAT_OPTION}
195
+ Exit status: 0 inspected; 2 not valid SARIF; 1 usage error or unreadable file.
196
+ `,
197
+ 'add-staged-changes': md `sarif-to-comment add-staged-changes — Add proposed changes from the Git index to a SARIF document.
198
+
199
+ Usage:
200
+ sarif-to-comment add-staged-changes --sarif IN --output OUT --worktree DIR
201
+ --repo OWNER/REPO --commit FULLSHA
202
+ [--source-root ABSOLUTE_FILE_URI] [--format human|json]
203
+
204
+ Reads what is staged in DIR's Git index (never unstaged working-tree content),
205
+ compares it with the reviewed commit, and writes a copy of IN to OUT in which
206
+ the staged changes are fixes on the findings they belong to. Source files and
207
+ the index are not changed.
208
+
209
+ Options:
210
+ --sarif IN SARIF file to start from (not changed).
211
+ --output OUT Where to write the result; must differ from IN.
212
+ --worktree DIR A directory in the Git working tree whose index is read.
213
+ --repo OWNER/REPO GitHub repository recorded as the fixes' source.
214
+ --commit FULLSHA Reviewed commit the staged changes are compared with.
215
+ --source-root ABSOLUTE_FILE_URI
216
+ Repository root in the SARIF producer's file system.
217
+ ${FORMAT_OPTION}
218
+ Existing output is preserved: if OUT exists it is first renamed to
219
+ "<UTC time>.old.<name>" beside it (its creation time, or its modification time
220
+ when the system does not record one). If the command then fails, no OUT is
221
+ written. While it runs, the command owns OUT through a marker file
222
+ ".<name>.sarif-to-comment-lock" beside it.
223
+
224
+ Exit status: 0 written; 2 invalid SARIF or a staged change that cannot be
225
+ represented faithfully (nothing written); 1 usage error or operational failure.
226
+ `,
227
+ publish: md `sarif-to-comment publish — publish a SARIF file as one GitHub draft review
228
+
229
+ Usage:
230
+ sarif-to-comment publish --sarif FILE --repo OWNER/REPO --pull N --commit FULLSHA
231
+ --state ABSOLUTE_FILE [--source-root ABSOLUTE_FILE_URI]
232
+ [--old-source-commit FULLSHA] [--ignore-approval-hold]
233
+ [--format human|json]
234
+
235
+ The same operation as the original form without a command.
236
+
237
+ Options:
238
+ ${PUBLISH_OPTIONS}${FORMAT_OPTION}
239
+ ${CREDENTIALS}
240
+ Exit status:
241
+ 0 published (or already published)
242
+ 2 blocked: nothing was published
243
+ 3 uncertain: delivery could not be confirmed; retry with the same --state
244
+ 1 usage error, unreadable SARIF file, refused request, or operational failure
245
+ `,
246
+ };
247
+ exports.USAGE = USAGE;
248
+ // ---------------------------------------------------------------------------
249
+ // Argument handling
250
+ // ---------------------------------------------------------------------------
251
+ /** A command-line mistake the user fixes by changing the arguments. */
252
+ class UsageError extends Error {
253
+ }
254
+ /**
255
+ * The argument at `index`, which the caller's loop bound keeps in range.
256
+ * (An out-of-range read would be a defect in this module, never user input.)
257
+ */
258
+ function argAt(argv, index) {
259
+ const arg = argv[index];
260
+ if (arg === undefined)
261
+ throw new Error(`Internal error: argument ${String(index)} is outside a list of ${String(argv.length)}.`);
262
+ return arg;
263
+ }
264
+ /**
265
+ * Removes the `--format` option from argv and resolves it. Throws UsageError
266
+ * (always reported in human form) when it is repeated, valueless or unknown.
267
+ */
268
+ function resolveFormat(argv) {
269
+ const rest = [];
270
+ let format;
271
+ let seen = false;
272
+ for (let i = 0; i < argv.length; i += 1) {
273
+ const arg = argAt(argv, i);
274
+ let value;
275
+ if (arg === '--format') {
276
+ const next = argv[i + 1];
277
+ if (next === undefined || next.startsWith('--'))
278
+ throw new UsageError('--format requires a value: human or json');
279
+ value = next;
280
+ i += 1;
281
+ }
282
+ else if (arg.startsWith('--format=')) {
283
+ value = arg.slice('--format='.length);
284
+ }
285
+ else {
286
+ rest.push(arg);
287
+ continue;
288
+ }
289
+ if (seen)
290
+ throw new UsageError('--format was given more than once');
291
+ seen = true;
292
+ if (value !== 'human' && value !== 'json')
293
+ throw new UsageError(`--format must be human or json, not ${JSON.stringify(value)}`);
294
+ format = value;
295
+ }
296
+ return { format: format ?? 'human', argv: rest };
297
+ }
298
+ /**
299
+ * Parses `--flag value` / `--flag=value` options exactly, as the flag-only
300
+ * publisher always has: unknown, repeated, valueless or empty options and
301
+ * positional arguments are usage errors. Returns { values: Map, flags: Set }.
302
+ */
303
+ function parseOptions(argv, { values: valueFlags, booleans = [], required = [] }) {
304
+ const values = new Map();
305
+ const flags = new Set();
306
+ for (let i = 0; i < argv.length; i += 1) {
307
+ const arg = argAt(argv, i);
308
+ if (!arg.startsWith('--'))
309
+ throw new UsageError(`unexpected argument ${arg}`);
310
+ const eq = arg.indexOf('=');
311
+ const name = eq === -1 ? arg : arg.slice(0, eq);
312
+ if (name === '--token') {
313
+ throw new UsageError('unknown option --token: the token is read only from GH_TOKEN or GITHUB_TOKEN');
314
+ }
315
+ if (booleans.includes(name)) {
316
+ if (eq !== -1)
317
+ throw new UsageError(`${name} takes no value`);
318
+ if (flags.has(name))
319
+ throw new UsageError(`${name} was given more than once`);
320
+ flags.add(name);
321
+ continue;
322
+ }
323
+ if (!valueFlags.includes(name))
324
+ throw new UsageError(`unknown option ${name}`);
325
+ if (values.has(name))
326
+ throw new UsageError(`${name} was given more than once`);
327
+ let value;
328
+ if (eq !== -1) {
329
+ value = arg.slice(eq + 1);
330
+ }
331
+ else {
332
+ const next = argv[i + 1];
333
+ if (next === undefined || next.startsWith('--'))
334
+ throw new UsageError(`${name} requires a value`);
335
+ value = next;
336
+ i += 1;
337
+ }
338
+ if (value === '')
339
+ throw new UsageError(`${name} requires a non-empty value`);
340
+ values.set(name, value);
341
+ }
342
+ const missing = required.filter((flag) => !values.has(flag));
343
+ if (missing.length > 0)
344
+ throw new UsageError(`missing required option ${missing.join(', ')}`);
345
+ return { values, flags };
346
+ }
347
+ /**
348
+ * A required option's value. parseOptions has already refused its absence,
349
+ * so a missing value is a defect in this module, never user input.
350
+ */
351
+ function requiredValue(values, flag) {
352
+ const value = values.get(flag);
353
+ if (value === undefined)
354
+ throw new Error(`Internal error: required option ${flag} was not parsed.`);
355
+ return value;
356
+ }
357
+ /** OWNER/REPO from a flag, validated as GitHub names; throws UsageError. */
358
+ function repositoryFlag(values, flag = '--repo') {
359
+ // String() is the conversion RegExp#exec applies itself.
360
+ const match = REPO_FLAG_PATTERN.exec(String(values.get(flag)));
361
+ const owner = match?.[1];
362
+ const repo = match?.[2];
363
+ if (owner === undefined || repo === undefined || !sarif_common_cjs_1.OWNER_PATTERN.test(owner) || !sarif_common_cjs_1.REPO_PATTERN.test(repo) || repo === '.' || repo === '..') {
364
+ throw new UsageError(`${flag} must be OWNER/REPO`);
365
+ }
366
+ return { owner, repo };
367
+ }
368
+ /** A full lowercase commit given for a flag; throws UsageError. */
369
+ function commitValue(value, flag) {
370
+ if (!COMMIT_FLAG_PATTERN.test(value)) {
371
+ throw new UsageError(`${flag} must be a full 40-character lowercase commit SHA`);
372
+ }
373
+ return value;
374
+ }
375
+ /** A full lowercase commit from a flag, or undefined; throws UsageError. */
376
+ function commitFlag(values, flag) {
377
+ const value = values.get(flag);
378
+ return value === undefined ? undefined : commitValue(value, flag);
379
+ }
380
+ /** `{ owner, repo, commit }` from --repo/--commit given together, or undefined. */
381
+ function optionalSource(values) {
382
+ const hasRepo = values.has('--repo');
383
+ const commit = values.get('--commit');
384
+ const hasCommit = commit !== undefined;
385
+ if (hasRepo && !hasCommit)
386
+ throw new UsageError('--repo requires --commit (give both or neither)');
387
+ if (hasCommit && !hasRepo)
388
+ throw new UsageError('--commit requires --repo (give both or neither)');
389
+ if (!hasRepo || !hasCommit)
390
+ return undefined;
391
+ const { owner, repo } = repositoryFlag(values);
392
+ return { owner, repo, commit: commitValue(commit, '--commit') };
393
+ }
394
+ /** A `--source-root` value, or undefined; throws UsageError. */
395
+ function sourceRootFlag(values) {
396
+ const value = values.get('--source-root');
397
+ if (value !== undefined && !(value.startsWith('file:') && value.endsWith('/'))) {
398
+ throw new UsageError('--source-root must be an absolute file: URI ending in "/"');
399
+ }
400
+ return value;
401
+ }
402
+ /** A positive whole number given for a flag; throws UsageError. */
403
+ function positiveValue(value, flag) {
404
+ if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value))) {
405
+ throw new UsageError(`${flag} must be a positive whole number`);
406
+ }
407
+ return Number(value);
408
+ }
409
+ /** A positive whole number from a flag; throws UsageError. */
410
+ function positiveFlag(values, flag) {
411
+ const value = values.get(flag);
412
+ if (value === undefined)
413
+ return undefined;
414
+ return positiveValue(value, flag);
415
+ }
416
+ /** A preview limit: a positive whole number, or "all" (no limit, null); throws UsageError. */
417
+ function previewFlag(values, flag) {
418
+ const value = values.get(flag);
419
+ if (value === undefined)
420
+ return undefined;
421
+ if (value === 'all')
422
+ return null;
423
+ if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value))) {
424
+ throw new UsageError(`${flag} must be a positive whole number or "all"`);
425
+ }
426
+ return Number(value);
427
+ }
428
+ /** The credential: GH_TOKEN, else GITHUB_TOKEN; empty counts as unset. */
429
+ function tokenFrom(env) {
430
+ const ghToken = env['GH_TOKEN'];
431
+ if (typeof ghToken === 'string' && ghToken !== '')
432
+ return ghToken;
433
+ const githubToken = env['GITHUB_TOKEN'];
434
+ if (typeof githubToken === 'string' && githubToken !== '')
435
+ return githubToken;
436
+ return undefined;
437
+ }
438
+ /**
439
+ * A caught value's `message` property, read as `value.message` reads it
440
+ * (a primitive has none; neither has null or undefined, which cannot be read).
441
+ */
442
+ function messageProperty(value) {
443
+ return (typeof value === 'object' && value !== null) || typeof value === 'function' ? Reflect.get(value, 'message') : undefined;
444
+ }
445
+ /** An error's message and cause chain, one line each. */
446
+ function describeError(err) {
447
+ const lines = [];
448
+ const seen = new Set();
449
+ let current = err;
450
+ while (current !== undefined && current !== null && !seen.has(current)) {
451
+ seen.add(current);
452
+ const message = messageProperty(current) ?? current;
453
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- a thrown value of any type is shown by its message, else by String(), the deliberate total coercion
454
+ lines.push(lines.length === 0 ? String(message) : ` caused by: ${String(message)}`);
455
+ if (!(current instanceof Error))
456
+ break;
457
+ current = current.cause;
458
+ }
459
+ return lines.join('\n');
460
+ }
461
+ /** Reads all of a readable stream as a Buffer. */
462
+ async function readStream(stream) {
463
+ const chunks = [];
464
+ for await (const chunk of stream)
465
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
466
+ return Buffer.concat(chunks);
467
+ }
468
+ /** SARIF as written by the CLI: two-space indented JSON with a final newline. */
469
+ function serialize(sarif) {
470
+ return `${JSON.stringify(sarif, null, 2)}\n`;
471
+ }
472
+ /** An operational failure after arguments were accepted, with its artifact receipt fields. */
473
+ function errorOutcome(command, message, receipt = {}, humanNotes = []) {
474
+ const notes = humanNotes.length === 0 ? '' : `\n${humanNotes.join('\n')}`;
475
+ return { exit: EXIT.error, doc: { command, status: 'error', message, ...receipt }, err: `sarif-to-comment: ${message}${notes}\n` };
476
+ }
477
+ /** A usage error; `usage` is the command's help (or the top-level help). */
478
+ function usageOutcome(command, message, legacy = false) {
479
+ const hint = legacy || command === null ? 'sarif-to-comment --help' : `sarif-to-comment ${command} --help`;
480
+ return {
481
+ exit: EXIT.usage,
482
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- every command has help text; the top-level fallback is kept as the durable guard it always was
483
+ doc: { command, status: 'usage-error', message, usage: USAGE[command ?? 'top'] ?? USAGE.top },
484
+ err: `sarif-to-comment: ${message}\nRun ${hint} for usage.\n`,
485
+ };
486
+ }
487
+ /** Human text for refused content: the library's Markdown and what happened to files. */
488
+ function refusedText(markdown, notes) {
489
+ const text = markdown.endsWith('\n') ? markdown : `${markdown}\n`;
490
+ return notes.length === 0 ? text : `${text}\n${notes.join('\n')}\n`;
491
+ }
492
+ /** A one-based line or inclusive range, written like inspection output (`2` or `5-6`). */
493
+ const lineRange = (start, end) => end === undefined || end === start ? String(start) : `${String(start)}-${String(end)}`;
494
+ // ---------------------------------------------------------------------------
495
+ // init
496
+ // ---------------------------------------------------------------------------
497
+ function init(argv, { cwd }) {
498
+ const { values } = parseOptions(argv, {
499
+ values: ['--output', '--tool-name', '--tool-version', '--repo', '--commit'],
500
+ required: ['--output'],
501
+ });
502
+ if (values.has('--tool-version') && !values.has('--tool-name')) {
503
+ throw new UsageError("--tool-version requires --tool-name (this package's version is never attributed to another tool)");
504
+ }
505
+ const source = optionalSource(values);
506
+ const toolName = values.get('--tool-name');
507
+ const toolVersion = values.get('--tool-version');
508
+ const options = {
509
+ ...(toolName === undefined ? {} : { tool: { name: toolName, ...(toolVersion === undefined ? {} : { version: toolVersion }) } }),
510
+ ...(source ? { source } : {}),
511
+ };
512
+ let sarif;
513
+ try {
514
+ sarif = (0, sarif_authoring_cjs_1.createSarifDocument)(options);
515
+ }
516
+ catch (err) {
517
+ if (err instanceof TypeError)
518
+ throw new UsageError(err.message);
519
+ throw err;
520
+ }
521
+ const output = path.resolve(cwd, requiredValue(values, '--output'));
522
+ try {
523
+ files.createExclusive(output, serialize(sarif));
524
+ }
525
+ catch (err) {
526
+ if (!(err instanceof files.ArtifactError))
527
+ throw err;
528
+ return errorOutcome('init', err.message, { output: { path: output, written: false } });
529
+ }
530
+ const run = createdRun(sarif.runs[0]);
531
+ const binding = run.versionControlProvenance?.[0];
532
+ const doc = {
533
+ command: 'init',
534
+ status: 'created',
535
+ output: { path: output, written: true },
536
+ runIndex: 0,
537
+ ...(binding ? { source: { repositoryUri: binding.repositoryUri, commit: binding.revisionId } } : {}),
538
+ };
539
+ const tool = run.tool.driver.version === undefined ? run.tool.driver.name : `${run.tool.driver.name} ${run.tool.driver.version}`;
540
+ const bound = binding ? `, bound to ${binding.repositoryUri} at ${binding.revisionId}` : ', not bound to a commit';
541
+ return {
542
+ exit: EXIT.ok,
543
+ doc,
544
+ out: `Created ${output}: a SARIF document with one run (run 0, tool "${tool}"${bound}).\n`,
545
+ };
546
+ }
547
+ /**
548
+ * The one run of a document createSarifDocument has just made. It always
549
+ * has that run (with a named driver and, when bound, its provenance), so
550
+ * this check refuses only a defect in this package, never user input.
551
+ */
552
+ function createdRun(run) {
553
+ if (!isCreatedRun(run))
554
+ throw new Error('Internal error: createSarifDocument returned no run with a named tool.');
555
+ return run;
556
+ }
557
+ /** Whether `value` has the shape createSarifDocument gives its run (see ICreatedRun). */
558
+ function isCreatedRun(value) {
559
+ if (typeof value !== 'object' || value === null || !('tool' in value))
560
+ return false;
561
+ const { tool } = value;
562
+ if (typeof tool !== 'object' || tool === null || !('driver' in tool))
563
+ return false;
564
+ const { driver } = tool;
565
+ if (typeof driver !== 'object' || driver === null || !('name' in driver) || typeof driver.name !== 'string')
566
+ return false;
567
+ if ('version' in driver && driver.version !== undefined && typeof driver.version !== 'string')
568
+ return false;
569
+ if (!('versionControlProvenance' in value) || value.versionControlProvenance === undefined)
570
+ return true;
571
+ const provenance = value.versionControlProvenance;
572
+ return (Array.isArray(provenance) &&
573
+ provenance.every((entry) => typeof entry === 'object' &&
574
+ entry !== null &&
575
+ 'repositoryUri' in entry &&
576
+ typeof entry.repositoryUri === 'string' &&
577
+ 'revisionId' in entry &&
578
+ typeof entry.revisionId === 'string'));
579
+ }
580
+ /** The SARIF levels `--level` accepts. */
581
+ const LEVELS = ['none', 'note', 'warning', 'error'];
582
+ function isLevel(value) {
583
+ return LEVELS.some((level) => level === value);
584
+ }
585
+ async function addComment(argv, { cwd, stdin }) {
586
+ const { values, flags } = parseOptions(argv, {
587
+ values: [
588
+ '--sarif', '--file', '--line', '--end-line', '--message', '--message-file', '--rule-id', '--level', '--run',
589
+ '--new-run-tool', '--new-run-tool-version', '--repo', '--commit',
590
+ ],
591
+ booleans: ['--markdown'],
592
+ required: ['--sarif', '--file', '--line'],
593
+ });
594
+ if (values.has('--message') === values.has('--message-file')) {
595
+ throw new UsageError('give exactly one of --message TEXT or --message-file FILE|-');
596
+ }
597
+ const file = requiredValue(values, '--file');
598
+ if (!(0, sarif_common_cjs_1.isNormalizedRepositoryPath)(file)) {
599
+ throw new UsageError('--file must be a repository-relative path with "/" separators and no leading "/", ".", ".." or empty segment');
600
+ }
601
+ const line = positiveValue(requiredValue(values, '--line'), '--line');
602
+ const endLine = positiveFlag(values, '--end-line');
603
+ if (endLine !== undefined && endLine < line)
604
+ throw new UsageError('--end-line must not be smaller than --line');
605
+ const level = values.get('--level');
606
+ if (level !== undefined && !isLevel(level)) {
607
+ throw new UsageError('--level must be none, note, warning or error');
608
+ }
609
+ const newRunTool = values.get('--new-run-tool');
610
+ if (values.has('--run') && newRunTool !== undefined)
611
+ throw new UsageError('--run and --new-run-tool cannot be combined');
612
+ for (const flag of ['--new-run-tool-version', '--repo', '--commit']) {
613
+ if (values.has(flag) && newRunTool === undefined)
614
+ throw new UsageError(`${flag} applies only to a new run: give --new-run-tool NAME`);
615
+ }
616
+ let run;
617
+ const index = values.get('--run');
618
+ if (index !== undefined) {
619
+ if (!/^(0|[1-9][0-9]*)$/.test(index))
620
+ throw new UsageError('--run must be a run index (0, 1, ...)');
621
+ run = Number(index);
622
+ }
623
+ else if (newRunTool !== undefined) {
624
+ const toolVersion = values.get('--new-run-tool-version');
625
+ const newRunVersion = toolVersion === undefined ? {} : { toolVersion };
626
+ const source = optionalSource(values);
627
+ run = { toolName: newRunTool, ...newRunVersion, ...(source ? { source } : {}) };
628
+ }
629
+ const sarifPath = path.resolve(cwd, requiredValue(values, '--sarif'));
630
+ const notWritten = { sarif: { path: sarifPath, written: false } };
631
+ let message;
632
+ try {
633
+ const inline = values.get('--message');
634
+ const messageFile = values.get('--message-file');
635
+ if (inline !== undefined) {
636
+ message = inline;
637
+ }
638
+ else if (messageFile === '-') {
639
+ message = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(await readStream(stdin));
640
+ }
641
+ else {
642
+ message = files.readTextFile(path.resolve(cwd, String(messageFile)), 'message file').text;
643
+ }
644
+ }
645
+ catch (err) {
646
+ if (err instanceof files.ArtifactError)
647
+ return errorOutcome('add-comment', err.message, notWritten);
648
+ if (err instanceof TypeError)
649
+ return errorOutcome('add-comment', 'standard input is not valid UTF-8; the message must be UTF-8 encoded.', notWritten);
650
+ throw err;
651
+ }
652
+ if (message === '')
653
+ throw new UsageError('the message must not be empty');
654
+ const ruleId = values.get('--rule-id');
655
+ const comment = {
656
+ file,
657
+ line,
658
+ message,
659
+ ...(endLine === undefined ? {} : { endLine }),
660
+ ...(flags.has('--markdown') ? { messageFormat: 'markdown' } : {}),
661
+ ...(ruleId === undefined ? {} : { ruleId }),
662
+ ...(level === undefined ? {} : { level }),
663
+ ...(run === undefined ? {} : { run }),
664
+ };
665
+ // Edit the real file behind any symbolic link, so the link itself survives.
666
+ let target;
667
+ try {
668
+ target = fs.realpathSync(sarifPath);
669
+ }
670
+ catch (err) {
671
+ return errorOutcome('add-comment', `cannot read SARIF file ${sarifPath}: ${String(messageProperty(err))}`, notWritten);
672
+ }
673
+ let release;
674
+ try {
675
+ release = files.acquireOwnership(target);
676
+ }
677
+ catch (err) {
678
+ if (err instanceof files.ArtifactError)
679
+ return errorOutcome('add-comment', err.message, notWritten);
680
+ throw err;
681
+ }
682
+ try {
683
+ let read;
684
+ try {
685
+ read = files.readJsonFile(target, 'SARIF file');
686
+ }
687
+ catch (err) {
688
+ if (err instanceof files.ArtifactError)
689
+ return errorOutcome('add-comment', err.message, notWritten);
690
+ throw err;
691
+ }
692
+ let outcome;
693
+ try {
694
+ outcome = (0, sarif_authoring_cjs_1.addSarifCommentWithUntypedInput)(read.value, comment);
695
+ }
696
+ catch (err) {
697
+ if (err instanceof TypeError) {
698
+ throw new UsageError(`${err.message}. Select a run with --run N, or add one with --new-run-tool NAME.`);
699
+ }
700
+ throw err;
701
+ }
702
+ if (outcome.status === 'invalid') {
703
+ return {
704
+ exit: EXIT.refused,
705
+ doc: { command: 'add-comment', status: 'invalid', ...notWritten, problems: outcome.problems },
706
+ out: refusedText(outcome.markdown, [`${sarifPath} was not changed.`]),
707
+ };
708
+ }
709
+ try {
710
+ files.replaceIfUnchanged(target, read.bytes, serialize(outcome.sarif));
711
+ }
712
+ catch (err) {
713
+ if (err instanceof files.ArtifactError)
714
+ return errorOutcome('add-comment', err.message, notWritten);
715
+ throw err;
716
+ }
717
+ const finding = { ref: outcome.finding.ref, path: file, line, endLine: endLine ?? line, tool: outcome.finding.tool };
718
+ return {
719
+ exit: EXIT.ok,
720
+ doc: { command: 'add-comment', status: 'added', sarif: { path: sarifPath, written: true }, finding },
721
+ out: `Added ${finding.ref} (tool "${finding.tool}") on ${file}:${lineRange(line, endLine)} to ${sarifPath}.\n`,
722
+ };
723
+ }
724
+ finally {
725
+ release();
726
+ }
727
+ }
728
+ // ---------------------------------------------------------------------------
729
+ // inspect
730
+ // ---------------------------------------------------------------------------
731
+ function inspect(argv, { cwd }) {
732
+ const { values } = parseOptions(argv, {
733
+ values: ['--sarif', '--preview-lines', '--preview-chars', '--source-root'],
734
+ required: ['--sarif'],
735
+ });
736
+ const previewLines = previewFlag(values, '--preview-lines');
737
+ const previewChars = previewFlag(values, '--preview-chars');
738
+ const sourceRootUri = sourceRootFlag(values);
739
+ const options = {
740
+ ...(previewLines === undefined ? {} : { previewLines }),
741
+ ...(previewChars === undefined ? {} : { previewChars }),
742
+ ...(sourceRootUri === undefined ? {} : { sourceRootUri }),
743
+ };
744
+ const sarifPath = path.resolve(cwd, requiredValue(values, '--sarif'));
745
+ let read;
746
+ try {
747
+ read = files.readJsonFile(sarifPath, 'SARIF file');
748
+ }
749
+ catch (err) {
750
+ if (err instanceof files.ArtifactError)
751
+ return errorOutcome('inspect', err.message);
752
+ throw err;
753
+ }
754
+ let outcome;
755
+ try {
756
+ outcome = (0, sarif_inspection_cjs_1.inspectSarifWithUntypedInput)(read.value, options);
757
+ }
758
+ catch (err) {
759
+ if (err instanceof TypeError)
760
+ throw new UsageError(err.message);
761
+ throw err;
762
+ }
763
+ if (outcome.status === 'invalid') {
764
+ return {
765
+ exit: EXIT.refused,
766
+ doc: { command: 'inspect', status: 'invalid', problems: outcome.problems },
767
+ out: refusedText(outcome.markdown, []),
768
+ };
769
+ }
770
+ const text = (0, sarif_inspection_cjs_1.renderInspectionText)(outcome.view);
771
+ return {
772
+ exit: EXIT.ok,
773
+ doc: { command: 'inspect', status: 'inspected', view: outcome.view },
774
+ out: text.endsWith('\n') ? text : `${text}\n`,
775
+ };
776
+ }
777
+ // ---------------------------------------------------------------------------
778
+ // add-staged-changes
779
+ // ---------------------------------------------------------------------------
780
+ /** Human lines describing an extraction receipt. */
781
+ function stagedReceiptText(receipt) {
782
+ const lines = [];
783
+ if (receipt.changes.length === 0)
784
+ lines.push('No staged changes: the SARIF content is unchanged.');
785
+ for (const change of receipt.changes) {
786
+ if (change.operation === 'edit') {
787
+ lines.push(`${change.operation} ${change.path}`);
788
+ for (const r of change.replacements ?? []) {
789
+ const who = r.associated.length === 0 ? 'no finding' : r.associated.join(', ');
790
+ // A pure insertion changes no reviewed line; its empty range ends at the
791
+ // line it follows, so name that position rather than a changed range.
792
+ const where = r.insertion
793
+ ? `${r.endLine === 0 ? 'insertion at the start of the file' : `insertion after line ${String(r.endLine)}`} (no reviewed line changed)`
794
+ : `lines ${lineRange(r.startLine, r.endLine)}`;
795
+ lines.push(` ${where}: ${who} (explained by ${r.explainedBy})`);
796
+ }
797
+ }
798
+ else {
799
+ const associated = change.associated ?? [];
800
+ const who = associated.length === 0 ? 'no finding' : associated.join(', ');
801
+ lines.push(`${change.operation} ${change.path}: ${who} (explained by ${String(change.explainedBy)})`);
802
+ }
803
+ }
804
+ if (receipt.boundRuns.length > 0)
805
+ lines.push(`Runs bound to ${receipt.reviewedCommit}: ${receipt.boundRuns.join(', ')}`);
806
+ if (receipt.addedRun !== null)
807
+ lines.push(`Changes no finding explains are in run ${String(receipt.addedRun)}.`);
808
+ for (const warning of receipt.warnings)
809
+ lines.push(`Warning: ${warning.message}`);
810
+ return lines;
811
+ }
812
+ /** Human line describing an archived output. */
813
+ function archiveNote(archived) {
814
+ if (!archived)
815
+ return [];
816
+ const time = archived.timeSource === 'birth' ? 'creation' : 'modification';
817
+ return [`The previous ${archived.from} was preserved as ${archived.path} (named by its ${time} time).`];
818
+ }
819
+ async function addStagedChanges(argv, { cwd }) {
820
+ const { values } = parseOptions(argv, {
821
+ values: ['--sarif', '--output', '--worktree', '--repo', '--commit', '--source-root'],
822
+ required: ['--sarif', '--output', '--worktree', '--repo', '--commit'],
823
+ });
824
+ const repository = repositoryFlag(values);
825
+ const reviewedCommit = commitValue(requiredValue(values, '--commit'), '--commit');
826
+ const sourceRootUri = sourceRootFlag(values);
827
+ const input = path.resolve(cwd, requiredValue(values, '--sarif'));
828
+ const output = path.resolve(cwd, requiredValue(values, '--output'));
829
+ const worktree = path.resolve(cwd, requiredValue(values, '--worktree'));
830
+ if (input === output || files.sameExistingFile(input, output)) {
831
+ throw new UsageError(`--output must be a different file from --sarif (${output} and ${input} are the same file)`);
832
+ }
833
+ let outputDirectory;
834
+ try {
835
+ outputDirectory = fs.statSync(path.dirname(output));
836
+ }
837
+ catch {
838
+ outputDirectory = null;
839
+ }
840
+ if (!outputDirectory || !outputDirectory.isDirectory()) {
841
+ throw new UsageError(`the --output directory ${path.dirname(output)} does not exist`);
842
+ }
843
+ // Operation start (§6.4): ownership, then archive, before any other work.
844
+ const command = 'add-staged-changes';
845
+ let release;
846
+ try {
847
+ release = files.acquireOwnership(output);
848
+ }
849
+ catch (err) {
850
+ if (err instanceof files.ArtifactError) {
851
+ return errorOutcome(command, err.message, { output: { path: output, written: false }, archived: null });
852
+ }
853
+ throw err;
854
+ }
855
+ let archived = null;
856
+ const receiptFields = () => ({ output: { path: output, written: false }, archived });
857
+ const notes = () => [`${output} was not written.`, ...archiveNote(archived)];
858
+ try {
859
+ try {
860
+ archived = files.archiveExisting(output);
861
+ }
862
+ catch (err) {
863
+ if (err instanceof files.ArtifactError)
864
+ return errorOutcome(command, err.message, receiptFields(), notes());
865
+ throw err;
866
+ }
867
+ let read;
868
+ try {
869
+ read = files.readJsonFile(input, 'SARIF file');
870
+ }
871
+ catch (err) {
872
+ if (err instanceof files.ArtifactError)
873
+ return errorOutcome(command, err.message, receiptFields(), notes());
874
+ throw err;
875
+ }
876
+ const request = {
877
+ sarif: read.value,
878
+ worktree,
879
+ reviewedCommit,
880
+ repository,
881
+ ...(sourceRootUri === undefined ? {} : { sourceRootUri }),
882
+ };
883
+ let outcome;
884
+ try {
885
+ outcome = await (0, staged_changes_cjs_1.addStagedChangesToSarifWithUntypedInput)(request);
886
+ }
887
+ catch (err) {
888
+ return errorOutcome(command, describeError(err), receiptFields(), notes());
889
+ }
890
+ if (outcome.status === 'invalid' || outcome.status === 'failed') {
891
+ return {
892
+ exit: EXIT.refused,
893
+ doc: { command, status: outcome.status, ...receiptFields(), problems: outcome.problems },
894
+ out: refusedText(outcome.markdown, notes()),
895
+ };
896
+ }
897
+ try {
898
+ files.createExclusive(output, serialize(outcome.sarif));
899
+ }
900
+ catch (err) {
901
+ if (err instanceof files.ArtifactError)
902
+ return errorOutcome(command, err.message, receiptFields(), notes());
903
+ throw err;
904
+ }
905
+ return {
906
+ exit: EXIT.ok,
907
+ doc: { command, status: 'added', output: { path: output, written: true }, archived, receipt: outcome.receipt },
908
+ out: `${[`Wrote ${output}.`, ...archiveNote(archived), ...stagedReceiptText(outcome.receipt)].join('\n')}\n`,
909
+ };
910
+ }
911
+ finally {
912
+ release();
913
+ }
914
+ }
915
+ // ---------------------------------------------------------------------------
916
+ // publish (and the flag-only publisher)
917
+ // ---------------------------------------------------------------------------
918
+ const PUBLISH_SPEC = {
919
+ values: ['--sarif', '--repo', '--pull', '--commit', '--state', '--source-root', '--old-source-commit'],
920
+ booleans: ['--ignore-approval-hold'],
921
+ required: ['--sarif', '--repo', '--pull', '--commit', '--state'],
922
+ };
923
+ /** Library input fields from publish options; throws UsageError. */
924
+ function publishRequest(argv) {
925
+ const { values, flags } = parseOptions(argv, PUBLISH_SPEC);
926
+ const repo = REPO_FLAG_PATTERN.exec(requiredValue(values, '--repo'));
927
+ const owner = repo?.[1];
928
+ const name = repo?.[2];
929
+ if (owner === undefined || name === undefined)
930
+ throw new UsageError('--repo must be OWNER/REPO');
931
+ const pull = requiredValue(values, '--pull');
932
+ if (!/^[1-9][0-9]*$/.test(pull) || !Number.isSafeInteger(Number(pull))) {
933
+ throw new UsageError('--pull must be a positive pull request number');
934
+ }
935
+ const statePath = requiredValue(values, '--state');
936
+ if (!path.isAbsolute(statePath))
937
+ throw new UsageError('--state must be an absolute file path');
938
+ const destination = { owner, repo: name, pullNumber: Number(pull) };
939
+ const reviewedCommit = commitValue(requiredValue(values, '--commit'), '--commit');
940
+ const oldSourceCommit = commitFlag(values, '--old-source-commit');
941
+ const sourceRootUri = sourceRootFlag(values);
942
+ const input = {
943
+ destination,
944
+ reviewedCommit,
945
+ statePath,
946
+ ...(oldSourceCommit === undefined ? {} : { oldSourceCommit }),
947
+ ...(sourceRootUri === undefined ? {} : { sourceRootUri }),
948
+ ...(flags.has('--ignore-approval-hold') ? { options: { ignoreApprovalHold: true } } : {}),
949
+ };
950
+ return { sarifPath: requiredValue(values, '--sarif'), input };
951
+ }
952
+ /**
953
+ * Publication. Human output is exactly the flag-only publisher's: the
954
+ * library's Markdown on stdout, errors on stderr. The SARIF path is used as
955
+ * given, as it always has been.
956
+ */
957
+ async function publish(argv, { env }, internals) {
958
+ const request = publishRequest(argv);
959
+ const token = tokenFrom(env);
960
+ if (token === undefined) {
961
+ return errorOutcome('publish', 'no GitHub token: set GH_TOKEN (or GITHUB_TOKEN) to a personal access token or user token.');
962
+ }
963
+ let sarif;
964
+ try {
965
+ ({ value: sarif } = files.readJsonFile(request.sarifPath, 'SARIF file'));
966
+ }
967
+ catch (err) {
968
+ if (!(err instanceof files.ArtifactError))
969
+ throw err;
970
+ // The flag-only publisher's wording for these failures, kept exactly.
971
+ const message = err.message.includes('is not valid UTF-8')
972
+ ? `SARIF file ${request.sarifPath} is not valid UTF-8; nothing was published. SARIF files must be UTF-8 encoded JSON.`
973
+ : err.message;
974
+ return errorOutcome('publish', message);
975
+ }
976
+ let outcome;
977
+ try {
978
+ outcome = await (0, publish_sarif_review_cjs_1.publishSarifReviewWithInternals)({ ...request.input, sarif, token }, internals);
979
+ }
980
+ catch (err) {
981
+ return errorOutcome('publish', describeError(err));
982
+ }
983
+ const doc = {
984
+ command: 'publish',
985
+ status: outcome.status,
986
+ ...('review' in outcome ? { review: { id: outcome.review.id, url: outcome.review.url } } : {}),
987
+ ...('statePath' in outcome && outcome.statePath ? { statePath: outcome.statePath } : {}),
988
+ message: outcome.markdown,
989
+ };
990
+ return {
991
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- every publication status has an exit status; the operational-error fallback is kept as the durable guard it always was
992
+ exit: PUBLISH_EXIT[outcome.status] ?? EXIT.error,
993
+ doc,
994
+ out: outcome.markdown.endsWith('\n') ? outcome.markdown : `${outcome.markdown}\n`,
995
+ };
996
+ }
997
+ const HANDLERS = {
998
+ init,
999
+ 'add-comment': addComment,
1000
+ inspect,
1001
+ 'add-staged-changes': addStagedChanges,
1002
+ publish,
1003
+ };
1004
+ /** Whether an argument names a command. */
1005
+ function isCommand(arg) {
1006
+ return COMMANDS.some((command) => command === arg);
1007
+ }
1008
+ /**
1009
+ * Runs the CLI and returns its exit status; expected failures never throw.
1010
+ *
1011
+ * @param io - `{ argv, env, stdout, stderr, stdin?, cwd? }` (see ICliIo)
1012
+ * @param internals - passed to publishSarifReview (private test seam; the
1013
+ * shipped executable's test wrapper injects a fake GitHub client through it)
1014
+ */
1015
+ async function main({ argv, env, stdout, stderr, stdin = process.stdin, cwd = process.cwd() }, internals) {
1016
+ const token = tokenFrom(env);
1017
+ const safe = (text) => (token === undefined ? text : text.split(token).join('[redacted]'));
1018
+ let format;
1019
+ let args;
1020
+ try {
1021
+ ({ format, argv: args } = resolveFormat(argv));
1022
+ }
1023
+ catch (err) {
1024
+ if (!(err instanceof UsageError))
1025
+ throw err;
1026
+ stderr.write(safe(`sarif-to-comment: ${err.message}\nRun sarif-to-comment --help for usage.\n`));
1027
+ return EXIT.usage;
1028
+ }
1029
+ const first = args[0];
1030
+ const legacy = first === undefined || first.startsWith('-');
1031
+ const command = first === undefined || legacy ? 'publish' : isCommand(first) ? first : null;
1032
+ const rest = legacy ? args : args.slice(1);
1033
+ let outcome;
1034
+ if (command === null) {
1035
+ outcome = usageOutcome(null, `unknown command ${String(first)}`);
1036
+ }
1037
+ else if (rest.includes('--help') || rest.includes('-h')) {
1038
+ const helpCommand = legacy ? null : command;
1039
+ const usage = USAGE[helpCommand ?? 'top'];
1040
+ outcome = { exit: EXIT.ok, doc: { command: helpCommand, status: 'help', usage }, out: usage };
1041
+ }
1042
+ else {
1043
+ try {
1044
+ outcome = await HANDLERS[command](rest, { env, stdin, cwd }, internals);
1045
+ }
1046
+ catch (err) {
1047
+ if (!(err instanceof UsageError))
1048
+ throw err;
1049
+ outcome = usageOutcome(command, err.message, legacy);
1050
+ }
1051
+ }
1052
+ if (format === 'json') {
1053
+ stdout.write(safe(`${JSON.stringify(outcome.doc, null, 2)}\n`));
1054
+ }
1055
+ else {
1056
+ if (outcome.out !== undefined)
1057
+ stdout.write(safe(outcome.out));
1058
+ if (outcome.err !== undefined)
1059
+ stderr.write(safe(outcome.err));
1060
+ }
1061
+ return outcome.exit;
1062
+ }