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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # sarif-to-comment
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 6d868ab: The implementation is now strict TypeScript, and the shipped TypeScript declarations are generated from it rather than written by hand.
8
+
9
+ - No change to the public API, the CLI or behavior. The declarations describe the same functions and types as before.
10
+ - The packaged runtime files moved from `src/` and `bin/` to `dist/`. The package entry points are unchanged: `require('sarif-to-comment')`, `import … from 'sarif-to-comment'`, its TypeScript types and the `sarif-to-comment` command resolve through `package.json` as before. Only code that reached into the package's internal file paths is affected.
11
+
3
12
  ## 0.2.0
4
13
 
5
14
  ### Minor Changes
package/README.md CHANGED
@@ -228,13 +228,28 @@ If that comparison can't establish the old side, you may pass `oldSourceCommit`
228
228
 
229
229
  ```sh
230
230
  pnpm install
231
- pnpm test # node --test test/*.test.cjs
231
+ pnpm run build # rebuild dist/ from src/, roll up the declarations, regenerate api-report/ and docs/api/
232
232
  pnpm run check # lint, types, API report/docs freshness, release plan, tests (read-only)
233
- pnpm run build # regenerate api-report/ and docs/api/ after changing types/index.d.ts
233
+ pnpm test # refuse a missing or stale dist/, then node --test "test/**/*.test.mts"
234
234
  pnpm changeset # describe a change for the next release
235
235
  ```
236
236
 
237
- The public TypeScript declarations are written by hand in `types/index.d.ts` and must match the CommonJS runtime in `src/index.cjs`. API Extractor checks them and writes a reviewable API report (`api-report/`) and a doc model. API Documenter renders the doc model as the Markdown reference in `docs/api/`. `pnpm run check` fails when either is out of date.
237
+ Development and release tooling need Node 22.18.0 or later, enforced by `devEngines` in `package.json`: the tests and the build, check and release scripts are TypeScript that Node runs directly by type stripping. The published package still needs only Node 22 or later (`engines`).
238
+
239
+ Run `pnpm run build` before `pnpm run check` or `pnpm test`, and again after changing sources or build configuration. The tests exercise, and the package ships, the built `dist/`. `pnpm test` (and therefore `pnpm run check`) refuses a `dist/` that is missing, incomplete, or was built from different sources or configuration; it compares content hashes of every build input and output, not modification times.
240
+
241
+ The implementation is strict TypeScript, and there are no hand-written declarations. The public TypeScript declarations are generated from the implementation. `src/public-api.cts` declares the public API, and the CommonJS runtime entry `src/index.cts` is checked at compile time to export exactly its functions. `tsc` emits per-module declarations, API Extractor rolls them up into the shipped `dist/sarif-to-comment.d.ts` and writes a reviewable API report (`api-report/`) and a doc model, and API Documenter renders the doc model as the Markdown reference in `docs/api/`. `pnpm run check` fails when either is out of date.
242
+
243
+ `pnpm run build` rewrites `api-report/` and `docs/api/` whenever the public API or its TSDoc changes; commit them with the change. CI and the publish workflow build and then fail if either differs from the committed copy.
244
+
245
+ **Repository layout.**
246
+ - `src/*.cts`: the implementation. `tsc` compiles each module to CommonJS in `dist/*.cjs`; `src/index.cts` is the package entry and `src/sarif-to-comment.cts` the CLI executable.
247
+ - `dist/`: build output. It is not committed; the package ships its runtime modules and the rolled-up declarations.
248
+ - `test/`: `node:test` suites (`*.test.mts`), fixtures and helpers, run against `dist/`.
249
+ - `scripts/*.mts`: build, type-check, API documentation and release-guard tooling.
250
+ - `api-report/` and `docs/api/`: generated by `pnpm run build` and committed, so API changes are reviewed.
251
+ - `vendor/`: the official SARIF 2.1.0 schema used for validation.
252
+ - `.changeset/`: pending release notes.
238
253
 
239
254
  ## Releasing
240
255
 
@@ -242,16 +257,16 @@ Releases use [Changesets](https://changesets.dev/) for versioning and [npm trust
242
257
 
243
258
  ### Versions stay below 1.0.0
244
259
 
245
- `MAXIMUM_RELEASE_MAJOR` in `scripts/release-guard.cjs` is `0`. Until someone deliberately raises it in a reviewed change, nothing can version or publish `1.0.0` or higher, including prereleases such as `1.0.0-rc.0`:
260
+ `MAXIMUM_RELEASE_MAJOR` in `scripts/release-guard.mts` is `0`. Until someone deliberately raises it in a reviewed change, nothing can version or publish `1.0.0` or higher, including prereleases such as `1.0.0-rc.0`:
246
261
 
247
262
  - **`pnpm run check`** (run in CI on every pull request) fails if a pending changeset would reach 1.0.0. A `major` changeset is refused with an explanation. It is never quietly converted to a smaller bump; choose `minor` yourself if the change shouldn't start 1.0.
248
263
  - **`pnpm run release:version`** refuses the same plans before `changeset version` changes any file.
249
264
  - **What counts as the plan.** The guard doesn't parse changeset files itself. It runs the real `changeset version` in a throwaway copy and judges the version and changelog entry Changesets produces, so any front matter Changesets accepts is judged by its actual effect. That includes quoted values such as `"sarif-to-comment": "major"`. A changeset Changesets can't read is refused, not ignored.
250
- - **The publish workflow** refuses any `package.json` version of 1.0.0 or higher, and so does the `prepublishOnly` backstop for a manual publish.
265
+ - **The publish workflow** refuses any `package.json` version of 1.0.0 or higher, and so does the `prepublishOnly` backstop for a manual publish, which also refuses a missing or stale `dist/`.
251
266
 
252
267
  Changesets pre mode (prereleases) is not part of this release path.
253
268
 
254
- **Deliberately releasing 1.0.** In a reviewed change, raise `MAXIMUM_RELEASE_MAJOR` to `1` and update the test in `test/release.test.cjs` that pins its value. That is the only step: a `major` changeset then versions and publishes `1.0.0` through the normal flow above, while `2.0.0` and above stay blocked.
269
+ **Deliberately releasing 1.0.** In a reviewed change, raise `MAXIMUM_RELEASE_MAJOR` to `1` and update the test in `test/release.test.mts` that pins its value. That is the only step: a `major` changeset then versions and publishes `1.0.0` through the normal flow above, while `2.0.0` and above stay blocked.
255
270
 
256
271
  ### Making a release
257
272
 
@@ -270,8 +285,8 @@ Changesets pre mode (prereleases) is not part of this release path.
270
285
  - pre mode is off;
271
286
  - the package metadata is publishable, with the exact repository URL;
272
287
  - the commit is on `main`;
273
- - npm is at least 11.5.1 and Node at least 22.14.0.
274
- - **When allowed:** it runs `pnpm run check`, packs the tarball, verifies it contains exactly the distribution files, and publishes that tarball.
288
+ - npm is at least 11.5.1 and Node at least 22.18.0.
289
+ - **When allowed:** it builds `dist/`, runs `pnpm run check`, packs the tarball, verifies it contains exactly the distribution files, and publishes that tarball.
275
290
 
276
291
  Publishes never overlap, and a publish in progress is never cancelled.
277
292
  4. **If publishing fails**, what to do depends on where the cause is. A version npm has already accepted can never be republished.
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+ /**
3
+ * File handling for the CLI's local SARIF artifacts. The library never touches
4
+ * files; the command-line interface uses this module so that every receipt it
5
+ * prints describes what actually happened on disk.
6
+ *
7
+ * Responsibilities and guarantees:
8
+ * - Reading: SARIF and message files are strict UTF-8 (a leading byte-order
9
+ * mark is an encoding signature and is removed); undecodable bytes are
10
+ * refused rather than replaced, so nothing is silently altered.
11
+ * - Exclusive creation (`createExclusive`): the content is written and
12
+ * flushed to a temporary sibling, then hard-linked to the destination,
13
+ * which fails if the destination exists. A file therefore appears whole or
14
+ * not at all, and an output that reappeared concurrently is never
15
+ * overwritten.
16
+ * - Ownership (`acquireOwnership`): cooperating sarif-to-comment commands
17
+ * editing or producing the same artifact exclude each other with an
18
+ * exclusively created marker file, `.<name>.sarif-to-comment-lock`, beside
19
+ * the artifact. A marker that already exists is reported, never taken over:
20
+ * only a person can know that its owner is gone. The owner removes it when
21
+ * it finishes, whatever the outcome.
22
+ * - In-place replacement (`replaceIfUnchanged`): the new content is written
23
+ * to a temporary sibling with the original mode; immediately before the
24
+ * atomic rename the file is re-read and compared with the bytes the edit
25
+ * was computed from, and an observed external change is refused. This is
26
+ * not a filesystem compare-and-swap: a non-cooperating writer acting
27
+ * between that check and the rename is outside the guarantee.
28
+ * - Output preservation (`archiveExisting`): an existing output is moved
29
+ * aside as `<YYYY-MM-DDTHH-mm-ss.SSSZ>.old.<name>` in its directory, the
30
+ * stamp being the file's birth time in UTC (or its modification time when
31
+ * the platform reports no birth time), with `-2`, `-3`, … appended to the
32
+ * stamp on collision. The archive is an exclusive hard link followed by
33
+ * removal of the original name, so it never overwrites anything.
34
+ *
35
+ * Failures a user must act on are `ArtifactError`s with actionable messages
36
+ * that name the paths involved.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.ArtifactError = void 0;
40
+ exports.readTextFile = readTextFile;
41
+ exports.readJsonFile = readJsonFile;
42
+ exports.createExclusive = createExclusive;
43
+ exports.ownershipMarkerFor = ownershipMarkerFor;
44
+ exports.acquireOwnership = acquireOwnership;
45
+ exports.replaceIfUnchanged = replaceIfUnchanged;
46
+ exports.archiveExisting = archiveExisting;
47
+ exports.sameExistingFile = sameExistingFile;
48
+ const crypto = require("node:crypto");
49
+ const fs = require("node:fs");
50
+ const path = require("node:path");
51
+ /** A file problem the user must resolve; the message names the paths. */
52
+ class ArtifactError extends Error {
53
+ }
54
+ exports.ArtifactError = ArtifactError;
55
+ /**
56
+ * A caught failure's `message`, read as a template would read `err.message`.
57
+ * Node's file-system calls throw Errors (SystemError), so this is their
58
+ * message; a value without one reads as "undefined".
59
+ */
60
+ function messageOf(err) {
61
+ return String(typeof err === 'object' && err !== null && 'message' in err ? err.message : undefined);
62
+ }
63
+ /** A caught failure's Node error `code` (such as `EEXIST`), or undefined when it has none. */
64
+ function codeOf(err) {
65
+ return typeof err === 'object' && err !== null && 'code' in err ? err.code : undefined;
66
+ }
67
+ /**
68
+ * Decoder for UTF-8 text files: `fatal` refuses invalid bytes instead of
69
+ * substituting U+FFFD; the default `ignoreBOM: false` removes a leading BOM.
70
+ */
71
+ const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
72
+ /** Reads a UTF-8 text file; `label` names it in errors (e.g. "message file"). */
73
+ function readTextFile(file, label) {
74
+ let bytes;
75
+ try {
76
+ bytes = fs.readFileSync(file);
77
+ }
78
+ catch (err) {
79
+ throw new ArtifactError(`cannot read ${label} ${file}: ${messageOf(err)}`);
80
+ }
81
+ try {
82
+ return { text: UTF8.decode(bytes), bytes };
83
+ }
84
+ catch {
85
+ throw new ArtifactError(`${label} ${file} is not valid UTF-8; it must be UTF-8 encoded.`);
86
+ }
87
+ }
88
+ /** Reads a UTF-8 JSON file: { value, bytes } (the bytes as read, for change detection). */
89
+ function readJsonFile(file, label) {
90
+ const { text, bytes } = readTextFile(file, label);
91
+ try {
92
+ const value = JSON.parse(text);
93
+ return { value, bytes };
94
+ }
95
+ catch (err) {
96
+ throw new ArtifactError(`${label} ${file} is not valid JSON: ${messageOf(err)}`);
97
+ }
98
+ }
99
+ /** Flushes a directory entry change (best effort where directories cannot be opened). */
100
+ function syncDirectory(dir) {
101
+ let fd;
102
+ try {
103
+ fd = fs.openSync(dir, 'r');
104
+ fs.fsyncSync(fd);
105
+ }
106
+ catch {
107
+ // Some platforms do not allow fsync on a directory; the rename/link itself is still atomic.
108
+ }
109
+ finally {
110
+ if (fd !== undefined)
111
+ fs.closeSync(fd);
112
+ }
113
+ }
114
+ /** Writes and flushes `text` to a new uniquely named temporary sibling of `file`. */
115
+ function writeTemporarySibling(file, text, mode) {
116
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${String(process.pid)}.${crypto.randomUUID()}.tmp`);
117
+ const fd = fs.openSync(temporary, 'wx', mode ?? 0o666);
118
+ try {
119
+ fs.writeFileSync(fd, text);
120
+ if (mode !== undefined)
121
+ fs.fchmodSync(fd, mode);
122
+ fs.fsyncSync(fd);
123
+ }
124
+ finally {
125
+ fs.closeSync(fd);
126
+ }
127
+ return temporary;
128
+ }
129
+ /** Removes a temporary file if it is still present. */
130
+ function discard(temporary) {
131
+ try {
132
+ fs.unlinkSync(temporary);
133
+ }
134
+ catch {
135
+ // Already gone.
136
+ }
137
+ }
138
+ /**
139
+ * Creates `file` with `text` only if it does not exist. `hooks.beforeLink`
140
+ * (tests only) runs after the temporary file is complete.
141
+ */
142
+ function createExclusive(file, text, hooks = {}) {
143
+ const temporary = writeTemporarySibling(file, text);
144
+ try {
145
+ if (hooks.beforeLink)
146
+ hooks.beforeLink();
147
+ try {
148
+ fs.linkSync(temporary, file);
149
+ }
150
+ catch (err) {
151
+ if (codeOf(err) === 'EEXIST') {
152
+ throw new ArtifactError(`${file} already exists (it may have been created by another program); it was left unchanged.`);
153
+ }
154
+ throw new ArtifactError(`cannot create ${file}: ${messageOf(err)}`);
155
+ }
156
+ }
157
+ finally {
158
+ discard(temporary);
159
+ }
160
+ syncDirectory(path.dirname(file));
161
+ }
162
+ /** The ownership marker for an artifact: `.<name>.sarif-to-comment-lock` in its directory. */
163
+ function ownershipMarkerFor(file) {
164
+ return path.join(path.dirname(file), `.${path.basename(file)}.sarif-to-comment-lock`);
165
+ }
166
+ /**
167
+ * Takes exclusive cooperating-writer ownership of `file` (which need not
168
+ * exist). Returns a release function. Throws ArtifactError if another owner
169
+ * holds it.
170
+ */
171
+ function acquireOwnership(file) {
172
+ const marker = ownershipMarkerFor(file);
173
+ const token = `${String(process.pid)} ${new Date().toISOString()} ${crypto.randomUUID()}\n`;
174
+ try {
175
+ fs.writeFileSync(marker, token, { flag: 'wx' });
176
+ }
177
+ catch (err) {
178
+ if (codeOf(err) === 'EEXIST') {
179
+ throw new ArtifactError(`${file} is being written by another sarif-to-comment command (ownership marker ${marker}). ` +
180
+ `Wait for it to finish. If no such command is running, the marker is stale: delete ${marker} and run again.`);
181
+ }
182
+ throw new ArtifactError(`cannot take ownership of ${file} (marker ${marker}): ${messageOf(err)}`);
183
+ }
184
+ return function release() {
185
+ // Remove only our own marker: never delete one another process created.
186
+ try {
187
+ if (fs.readFileSync(marker, 'utf8') === token)
188
+ fs.unlinkSync(marker);
189
+ }
190
+ catch {
191
+ // Already removed.
192
+ }
193
+ };
194
+ }
195
+ /**
196
+ * Atomically replaces `file` with `text`, provided its content still equals
197
+ * `expectedBytes` immediately before the rename. `hooks.beforeReplace` (tests
198
+ * only) runs after the temporary file is complete.
199
+ */
200
+ function replaceIfUnchanged(file, expectedBytes, text, hooks = {}) {
201
+ const { mode } = fs.statSync(file);
202
+ const temporary = writeTemporarySibling(file, text, mode & 0o7777);
203
+ try {
204
+ if (hooks.beforeReplace)
205
+ hooks.beforeReplace();
206
+ let current;
207
+ try {
208
+ current = fs.readFileSync(file);
209
+ }
210
+ catch (err) {
211
+ throw new ArtifactError(`${file} could not be re-read before replacement (${messageOf(err)}); it was not changed.`);
212
+ }
213
+ if (!current.equals(expectedBytes)) {
214
+ throw new ArtifactError(`${file} changed while this command was running; it was not overwritten. Run the command again.`);
215
+ }
216
+ fs.renameSync(temporary, file);
217
+ }
218
+ finally {
219
+ discard(temporary);
220
+ }
221
+ syncDirectory(path.dirname(file));
222
+ }
223
+ /** A stat time as the archive stamp, `YYYY-MM-DDTHH-mm-ss.SSSZ` (colons are not portable in names). */
224
+ function archiveStamp(milliseconds) {
225
+ return new Date(milliseconds).toISOString().replace(/:/g, '-');
226
+ }
227
+ /**
228
+ * Moves an existing `file` aside under its archive name. Returns
229
+ * { path, from, timeSource: 'birth' | 'modified' }, or null if `file` does not
230
+ * exist. `options.stat` (tests only) replaces fs.statSync.
231
+ */
232
+ function archiveExisting(file, { stat = fs.statSync } = {}) {
233
+ let info;
234
+ try {
235
+ info = stat(file);
236
+ }
237
+ catch (err) {
238
+ if (codeOf(err) === 'ENOENT')
239
+ return null;
240
+ throw new ArtifactError(`cannot examine existing output ${file}: ${messageOf(err)}`);
241
+ }
242
+ const birth = Number.isFinite(info.birthtimeMs) && info.birthtimeMs > 0;
243
+ const stamp = archiveStamp(birth ? info.birthtimeMs : info.mtimeMs);
244
+ const dir = path.dirname(file);
245
+ const name = path.basename(file);
246
+ for (let n = 1;; n += 1) {
247
+ const archive = path.join(dir, `${stamp}${n === 1 ? '' : `-${String(n)}`}.old.${name}`);
248
+ try {
249
+ fs.linkSync(file, archive);
250
+ }
251
+ catch (err) {
252
+ if (codeOf(err) === 'EEXIST')
253
+ continue;
254
+ throw new ArtifactError(`cannot archive existing output ${file} as ${archive}: ${messageOf(err)}`);
255
+ }
256
+ fs.unlinkSync(file);
257
+ syncDirectory(dir);
258
+ return { path: archive, from: file, timeSource: birth ? 'birth' : 'modified' };
259
+ }
260
+ }
261
+ /** True when two existing paths name the same file (including symbolic and hard links). */
262
+ function sameExistingFile(a, b) {
263
+ let sa;
264
+ let sb;
265
+ try {
266
+ sa = fs.statSync(a);
267
+ sb = fs.statSync(b);
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ return sa.dev === sb.dev && sa.ino === sb.ino;
273
+ }