@cosyte/script-utils 0.1.7 → 0.1.9
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/CHANGELOG.md +16 -0
- package/README.md +99 -3
- package/bin/cosyte-docs-content.js +31 -0
- package/bin/cosyte-phi-scan.js +7 -2
- package/bin/cosyte-ratchet.js +27 -0
- package/bin/script-utils.js +2 -0
- package/catalog.json +1 -1
- package/changes.d.ts +78 -0
- package/changes.js +269 -0
- package/docs-content.d.ts +182 -0
- package/docs-content.js +833 -0
- package/package.json +28 -5
- package/phi-scan.d.ts +4 -1
- package/phi-scan.js +86 -8
- package/ratchet.d.ts +127 -0
- package/ratchet.js +696 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 65208bf: The docs-content check (`cosyte-docs-content`, `@cosyte/script-utils/docs-content`) now resolves every link a page makes into the generated reference, `./api/<page>.md#<anchor>`: the page must exist under `docs-content/api/`, and the anchor must be one a heading on it gets on docs.cosyte.com (lower case, every character but a letter, a mark, a digit, an underscore, a space or a hyphen dropped, each space a hyphen, a repeat numbered `-1`, `-2`). A link that does not resolve is a finding under the new rule `reference-link`, with its page and line. It also requires the "Current numbers" section of both quality pages to hold only the placeholder, `NUMBERS_PLACEHOLDER`, until the release fills in the numbers: a table, a sub-heading or a sentence added beside it is a finding. `readDocsContent` now returns the generated pages as `reference`, and `headingAnchors(text)` is exported.
|
|
8
|
+
- 57847cb: A new subpath, `@cosyte/script-utils/docs-content`, and its bin, `cosyte-docs-content` (also `script-utils docs-content`): the structure check for a library's `docs-content/` folder, against the docs-content standard (decision record 0006 in cosyte/config). It checks the sidebar's shape (`intro`, then Installation, Quickstart, Tutorials, How-to guides, Explanation, Reference and Troubleshooting in that order, then the pages `testing-and-quality` and `performance`), that every page is listed once and every listed id is a page, the required pages, each page's front matter (`id` equal to the file name, `title`, `sidebar_label`, a `description` of at most 160 characters, no `sidebar_position`), page names, and the "Current numbers" section on both quality pages. Exit 0 clean, 1 a finding, 2 the check could not read its input. Nothing else in the package changes.
|
|
9
|
+
- cc43161: The PHI scan's command-line form (`cosyte-phi-scan`, `script-utils phi-scan` and `node phi-scan.js`) takes `--allow-list <path>`, the allow list to use instead of `scripts/phi-allow-list.txt`. It goes with `--staged`, with paths, or alone; a relative path is read from the working directory, and the repository's own list is not read. A tool scanning a repository that keeps no allow list can pass an empty file and get a scan with no exceptions, where the scan used to refuse at exit 2 for the missing list. A path the option names that does not exist, or that cannot be read (a directory, no read permission), is refused at exit 2 before anything is scanned, with a line saying which. So are the option with no path, an empty path, and the option given twice. Without the option nothing changes: a repository with no `scripts/phi-allow-list.txt` still refuses at exit 2. `runPhiScan` is unchanged; its `allowListPath` setting already did this for a caller script.
|
|
10
|
+
- 5a0470a: Adds the coverage and mutation ratchet: the `cosyte-ratchet` bin (also `script-utils ratchet`) and the `./ratchet` entry point. `cosyte-ratchet check` reads a floor file, `quality-floor.json`, holding each package's coverage (lines, branches, functions, statements) and mutation score, compares what was measured, prints each metric with its floor and its gap to 100, and exits 1 on any drop. With `--base <ref>` it also requires every changed line coverage counts to have run and every mutant in changed code to be killed, unless an inline comment names a reason (`/* v8 ignore next -- reason */`, `// Stryker disable next-line all: reason`), and lists each such exception in changed code; an exception with no reason fails. With `--base` it also reads the floor file as it was at the merge base and exits 1 when the change lowered or removed any floor in it; raising or adding a floor passes. `cosyte-ratchet raise` moves floors up to what was measured and never lowers one. Exit 2 means an input could not be read. Also adds `./changes`, `readChanges({ base })`: the lines a change touched against a base ref, read from git, which the ratchet and `@cosyte/vitest-config/mutate` share, and `readFileAtCommit({ commit, path, cwd })`, a file as it was at a commit. The base ref and the path reach git on standard input, never as arguments.
|
|
11
|
+
|
|
12
|
+
## 0.1.8
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 7dee328: The catalog carries `@cosyte/cli`'s description from its latest npm release, which no longer ends
|
|
17
|
+
with the sentence on value-free diagnostics and the exit-code contract.
|
|
18
|
+
|
|
3
19
|
## 0.1.7
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -200,6 +200,9 @@ CATALOG.engine.displayName; // => "Pathways"
|
|
|
200
200
|
| `@cosyte/script-utils/claude-card` | `runClaudeCardGate(options)` and `checkClaudeCard(text)`, the CLAUDE.md card checker |
|
|
201
201
|
| `@cosyte/script-utils/catalog` | `CATALOG`, `catalogEntry(name)` and `runCatalogCheck(options)`, the catalog of our libraries |
|
|
202
202
|
| `@cosyte/script-utils/agent-notes` | deprecated (D62): `runAgentNotesCheck(options)` and `checkAgentNotes(input)` |
|
|
203
|
+
| `@cosyte/script-utils/ratchet` | `runRatchet(options)` and its parts, the coverage and mutation ratchet |
|
|
204
|
+
| `@cosyte/script-utils/changes` | `readChanges({ base })`, the lines a change touched against a base ref, read from git |
|
|
205
|
+
| `@cosyte/script-utils/docs-content` | `runDocsContentCheck(options)` and `checkDocsContent(input)`, the docs-content check |
|
|
203
206
|
|
|
204
207
|
They are separate subpaths because they are separately adoptable: a repo can take the entry-point
|
|
205
208
|
guard without taking a position on PHI scanning, a repo can take the internal-reference gate without
|
|
@@ -226,9 +229,14 @@ Each shared gate runs from a repository's package script with no file of its own
|
|
|
226
229
|
whole repository as the scan root; `--staged` reading everything but Markdown; the allow-list at
|
|
227
230
|
`scripts/phi-allow-list.txt` and the override log at `phi-scan-overrides.md`. Each line of
|
|
228
231
|
`scripts/phi-scan.exclude` is one literal tracked path (`#` lines are comments); a directory, a
|
|
229
|
-
pattern, a repeat or a path git does not track is refused at exit 2.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
pattern, a repeat or a path git does not track is refused at exit 2. `--allow-list <path>`,
|
|
233
|
+
with `--staged`, with paths or alone, uses that allow list instead of
|
|
234
|
+
`scripts/phi-allow-list.txt` (a relative path is read from the working directory, and the
|
|
235
|
+
repository's own list is not read). A tool scanning a repository that keeps no allow list passes
|
|
236
|
+
an empty file, and the floor then has no exceptions; without the option that repository refuses
|
|
237
|
+
at exit 2. A path the option names that does not exist or cannot be read is refused at exit 2. It
|
|
238
|
+
carries the cross-cutting floor only, so a repository whose standard needs field-level detectors
|
|
239
|
+
keeps a caller passing `detect` until the per-standard detectors ship here.
|
|
232
240
|
- **Internal refs** reads its axes from `scripts/internal-refs.json` (keys starting with `$` are
|
|
233
241
|
notes), with exit codes 0 clean, 1 hits, 2 the gate could not run. The file may not set
|
|
234
242
|
`exitCodes`, `repoRoot` or `write`, which the command-line form owns.
|
|
@@ -254,6 +262,8 @@ npx -y -p @cosyte/script-utils@0.1 cosyte-readme # or one gate's own bin
|
|
|
254
262
|
| `cosyte-claude-card` | `claude-card` | the CLAUDE.md card checker |
|
|
255
263
|
| `cosyte-agent-notes` | `agent-notes` | deprecated (D62): the retired agent-notes checker |
|
|
256
264
|
| `cosyte-catalog` | `catalog` | the catalog check |
|
|
265
|
+
| `cosyte-ratchet` | `ratchet` | the coverage and mutation ratchet |
|
|
266
|
+
| `cosyte-docs-content` | `docs-content` | the docs-content structure check |
|
|
257
267
|
|
|
258
268
|
Each runs over the working directory, takes the arguments its gate takes, exits with the gate's own
|
|
259
269
|
code, and prints what it does with `--help`. `script-utils` with no command prints the list and exits 2.
|
|
@@ -384,6 +394,87 @@ release, because config's check against npm picks the change up once the library
|
|
|
384
394
|
2 means the check could not run (no package.json, or an argument it does not accept). `--strict`
|
|
385
395
|
restores the gate: 1 for a disagreement, 2 for an unlisted package.
|
|
386
396
|
|
|
397
|
+
### The coverage and mutation ratchet
|
|
398
|
+
|
|
399
|
+
`cosyte-ratchet` holds a repository to a floor file at its root, `quality-floor.json`: each package
|
|
400
|
+
directory, relative to the file, with its coverage (lines, branches, functions, statements) and its
|
|
401
|
+
mutation score. A floor only rises: `raise` never lowers one, and `check --base` fails a change
|
|
402
|
+
that lowers or removes one.
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
cosyte-ratchet check --skip mutation # coverage against its floor, every run
|
|
406
|
+
cosyte-ratchet check --skip mutation --base origin/main # and a pull request's changed lines
|
|
407
|
+
cosyte-ratchet check --skip coverage --base origin/main # the mutants in a pull request's changed code
|
|
408
|
+
cosyte-ratchet raise # move floors up to what was measured
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`check` prints each metric, its floor and its gap to 100, and exits 1 on any drop. With `--base` it
|
|
412
|
+
also holds the change's own code to 100: every changed line coverage counts must have run, and
|
|
413
|
+
every mutant in changed code must be killed, unless an inline comment names its reason
|
|
414
|
+
(`/* v8 ignore next -- reason */` for coverage, `// Stryker disable next-line all: reason` for a
|
|
415
|
+
mutant). It lists every exception it finds in changed code, so the pull request can carry the list,
|
|
416
|
+
and an exception with no reason fails. With `--base` it also reads the floor file as it was at the
|
|
417
|
+
merge base and exits 1 when the change lowered any floor in it or removed one; raising a floor or
|
|
418
|
+
adding one passes. `raise` never lowers a floor. Exit 2 means an input could not be read: the floor
|
|
419
|
+
file now or at the merge base, a report a floor needs, or git.
|
|
420
|
+
|
|
421
|
+
It reads Istanbul's `coverage/coverage-summary.json` and `coverage/lcov.info` (which
|
|
422
|
+
`@cosyte/vitest-config` writes), and StrykerJS's `reports/mutation/mutation.json` and `changed.json`
|
|
423
|
+
(which `@cosyte/vitest-config/stryker` writes), from each package directory. A changed line any package's
|
|
424
|
+
suite ran counts as run.
|
|
425
|
+
|
|
426
|
+
`@cosyte/script-utils/changes` is how both the ratchet and `@cosyte/vitest-config/mutate` read what a
|
|
427
|
+
change touched: the diff from the merge base of the base ref and HEAD to the working tree, plus
|
|
428
|
+
untracked files, as each file's changed lines. `readFileAtCommit({ commit, path, cwd })` reads a
|
|
429
|
+
file as it was at a commit, which is how the ratchet reads the floor file at the merge base. A base
|
|
430
|
+
ref or a path reaches git on standard input, never as an argument, so no value can become a git
|
|
431
|
+
option.
|
|
432
|
+
|
|
433
|
+
```ts runnable
|
|
434
|
+
import { parseUnifiedDiff } from "@cosyte/script-utils/changes";
|
|
435
|
+
|
|
436
|
+
const diff = ["+++ b/src/a.ts", "@@ -3 +3,2 @@"].join("\n");
|
|
437
|
+
[...(parseUnifiedDiff(diff).get("src/a.ts") ?? [])]; // => [3, 4]
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
```ts runnable
|
|
441
|
+
import { parseFloor, toFloor } from "@cosyte/script-utils/ratchet";
|
|
442
|
+
|
|
443
|
+
toFloor(98.056); // => 98.05
|
|
444
|
+
const floor = parseFloor({ packages: { ".": { coverage: { lines: 98.05 }, mutation: 81.2 } } });
|
|
445
|
+
floor.packages["."].mutation; // => 81.2
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### The docs-content check
|
|
449
|
+
|
|
450
|
+
`@cosyte/script-utils/docs-content` checks a library's `docs-content/` folder, the pages
|
|
451
|
+
docs.cosyte.com renders, against the docs-content standard (decision record 0006 in this
|
|
452
|
+
repository). Run it from a package script:
|
|
453
|
+
|
|
454
|
+
```sh
|
|
455
|
+
cosyte-docs-content # docs-content/ in the working directory
|
|
456
|
+
cosyte-docs-content --dir <path> # another repository
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
It checks the sidebar's shape (`intro`, then Installation, Quickstart, Tutorials, How-to guides,
|
|
460
|
+
Explanation, Reference and Troubleshooting in that order, then `testing-and-quality` and
|
|
461
|
+
`performance`), that every page is listed once and every listed id is a page, the required pages,
|
|
462
|
+
each page's front matter, page names, and the "Current numbers" section on both quality pages,
|
|
463
|
+
which holds only the placeholder sentence until the release fills in the numbers. It also resolves
|
|
464
|
+
every link into the generated reference, `./api/<page>.md#<anchor>`: the page must exist under
|
|
465
|
+
`api/`, and the anchor must be one of its headings, slugged the way docs.cosyte.com slugs them. It
|
|
466
|
+
does not read any other link, check the generated pages themselves, or run a sample: the shared
|
|
467
|
+
CI's link check, `cosyte-docs-reference` in `@cosyte/process` and the sample runner in
|
|
468
|
+
`@cosyte/vitest-config` do those. Exit 0 clean, 1 a finding, 2 the check could not run.
|
|
469
|
+
|
|
470
|
+
```ts runnable
|
|
471
|
+
import { checkDocsContent } from "@cosyte/script-utils/docs-content";
|
|
472
|
+
|
|
473
|
+
const found = checkDocsContent({ pages: [{ name: "intro", text: "# Intro\n" }], sidebars: null });
|
|
474
|
+
found.map((f) => f.rule).includes("front-matter"); // => true
|
|
475
|
+
found.map((f) => f.rule).includes("required-page"); // => true
|
|
476
|
+
```
|
|
477
|
+
|
|
387
478
|
### The agent-notes checker, deprecated
|
|
388
479
|
|
|
389
480
|
`@cosyte/script-utils/agent-notes` and its bin, `cosyte-agent-notes`, are deprecated. They check a
|
|
@@ -442,6 +533,11 @@ evasion per round: each of `--quiet`, `-q`, `--format json`, `-fjson`, `-qP`, `-
|
|
|
442
533
|
Widening that set is a deliberate one-line edit in this package, reviewed here, and not something a
|
|
443
534
|
caller can do. No net can be switched off.
|
|
444
535
|
|
|
536
|
+
`cosyte-ratchet` takes the floor file's path, the base ref, and `--skip coverage` or
|
|
537
|
+
`--skip mutation` for a run that does not make that report; a skipped metric is printed as skipped,
|
|
538
|
+
never as passed. It has no way to lower a floor, and a floor file with a key it does not know is
|
|
539
|
+
refused rather than read around: a misspelled metric would be a floor nobody checks.
|
|
540
|
+
|
|
445
541
|
## PHI and safety
|
|
446
542
|
|
|
447
543
|
This package ships the machinery of the PHI commit-gate, so it is the one package here that reads
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cosyte-docs-content: the docs-content structure check for the repository in the working directory.
|
|
3
|
+
// It forwards every argument to the check and exits with the check's own code; `--help` prints what
|
|
4
|
+
// it does and exits 0, or 2 under CI.
|
|
5
|
+
|
|
6
|
+
import { runDocsContentCheck } from "../docs-content.js";
|
|
7
|
+
import { answerHelp } from "./help.js";
|
|
8
|
+
|
|
9
|
+
const HELP = `cosyte-docs-content: check docs-content/ against the docs-content standard.
|
|
10
|
+
|
|
11
|
+
cosyte-docs-content [--dir <repository root>]
|
|
12
|
+
|
|
13
|
+
It reads docs-content/ and checks the shape the standard gives it: one "docs" sidebar that opens
|
|
14
|
+
with "intro", then the categories Installation, Quickstart, Tutorials, How-to guides, Explanation,
|
|
15
|
+
Reference and Troubleshooting in that order (Tutorials and Reference only when a library has them),
|
|
16
|
+
then the pages "testing-and-quality" and "performance". Every page is listed once, every listed id
|
|
17
|
+
is a page, the required pages exist, every page opens with front matter carrying id (the file
|
|
18
|
+
name), title, sidebar_label and description (at most 160 characters), page names are lower-case
|
|
19
|
+
and hyphenated, and both quality pages carry a "## Current numbers" section.
|
|
20
|
+
|
|
21
|
+
It does not read links (the shared CI's docs-content link check does), the generated pages under
|
|
22
|
+
api/ (cosyte-docs-reference checks those), or whether a sample runs (the sample runner in
|
|
23
|
+
@cosyte/vitest-config does). Exit 0 clean, 1 a finding, 2 the check could not run.
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
28
|
+
process.exitCode = answerHelp("cosyte-docs-content", HELP);
|
|
29
|
+
} else {
|
|
30
|
+
process.exitCode = runDocsContentCheck({ argv: args });
|
|
31
|
+
}
|
package/bin/cosyte-phi-scan.js
CHANGED
|
@@ -13,10 +13,15 @@ const HELP = `cosyte-phi-scan: run the PHI commit-gate over the repository in th
|
|
|
13
13
|
cosyte-phi-scan --staged scan what is staged, Markdown excepted (the pre-commit route)
|
|
14
14
|
cosyte-phi-scan <path>... scan these paths
|
|
15
15
|
|
|
16
|
+
--allow-list <path> use this allow list instead of scripts/phi-allow-list.txt;
|
|
17
|
+
it goes with any of the three forms above
|
|
18
|
+
|
|
16
19
|
It carries the cross-cutting floor (a dashed SSN shape, an email at an undeclared domain) and
|
|
17
20
|
reports a place and a rule, never the matched value. scripts/phi-allow-list.txt declares synthetic
|
|
18
|
-
values, and scripts/phi-scan.exclude names literal tracked paths to leave out.
|
|
19
|
-
|
|
21
|
+
values, and scripts/phi-scan.exclude names literal tracked paths to leave out. Without that allow
|
|
22
|
+
list the scan refuses, unless --allow-list names another: an empty file there scans a repository
|
|
23
|
+
that keeps no list with no exceptions. A path --allow-list names that does not exist or cannot be
|
|
24
|
+
read refuses. Exit 0 clean, 1 hits, 2 the scan could not account for something.
|
|
20
25
|
`;
|
|
21
26
|
|
|
22
27
|
const args = process.argv.slice(2);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cosyte-ratchet: the coverage and mutation ratchet over the repository's floor file.
|
|
3
|
+
// A bin of @cosyte/script-utils, so a repository with no package.json can run it with
|
|
4
|
+
// `npx -y -p @cosyte/script-utils cosyte-ratchet check`. It forwards every argument to the ratchet
|
|
5
|
+
// and exits with its code; `--help` prints what it does and exits 0, or 2 under CI.
|
|
6
|
+
|
|
7
|
+
import { runRatchet } from "../ratchet.js";
|
|
8
|
+
import { answerHelp } from "./help.js";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
12
|
+
let help = "";
|
|
13
|
+
runRatchet({
|
|
14
|
+
argv: ["--help"],
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
out: (text) => (help += text),
|
|
17
|
+
err: () => {},
|
|
18
|
+
});
|
|
19
|
+
process.exitCode = answerHelp("cosyte-ratchet", help);
|
|
20
|
+
} else {
|
|
21
|
+
process.exitCode = runRatchet({
|
|
22
|
+
argv: args,
|
|
23
|
+
cwd: process.cwd(),
|
|
24
|
+
out: (text) => process.stdout.write(text),
|
|
25
|
+
err: (text) => process.stderr.write(text),
|
|
26
|
+
});
|
|
27
|
+
}
|
package/bin/script-utils.js
CHANGED
|
@@ -21,6 +21,8 @@ const COMMANDS = {
|
|
|
21
21
|
"claude-card": ["cosyte-claude-card", "the CLAUDE.md card checker"],
|
|
22
22
|
"agent-notes": ["cosyte-agent-notes", "the agent-notes checker, deprecated by D62"],
|
|
23
23
|
catalog: ["cosyte-catalog", "the catalog check, for the library here"],
|
|
24
|
+
ratchet: ["cosyte-ratchet", "the coverage and mutation ratchet, over the floor file here"],
|
|
25
|
+
"docs-content": ["cosyte-docs-content", "the docs-content structure check, for the library here"],
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
const HELP = `script-utils: run one of the @cosyte/script-utils gates over the working directory.
|
package/catalog.json
CHANGED
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
{
|
|
182
182
|
"name": "@cosyte/cli",
|
|
183
183
|
"slug": "cli",
|
|
184
|
-
"description": "The Cosyte command line for Node.js and TypeScript: one command over HL7 v2, FHIR R4, X12, ASTM, NCPDP, C-CDA, DICOM, and MLLP, with HL7 v2 to FHIR conversion from transform, code mapping from terminology, and the cosyte-mcp MCP server for agents.
|
|
184
|
+
"description": "The Cosyte command line for Node.js and TypeScript: one command over HL7 v2, FHIR R4, X12, ASTM, NCPDP, C-CDA, DICOM, and MLLP, with HL7 v2 to FHIR conversion from transform, code mapping from terminology, and the cosyte-mcp MCP server for agents.",
|
|
185
185
|
"line": "0.1",
|
|
186
186
|
"engines": {
|
|
187
187
|
"node": ">=22.0.0"
|
package/changes.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** The result of running git: its exit status (`null` when it could not start) and its output. */
|
|
2
|
+
export interface GitResult {
|
|
3
|
+
status: number | null;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Runs git with array arguments in a directory, writing `input`, when given, to its standard input. */
|
|
9
|
+
export type GitRunner = (args: string[], cwd: string, input?: string) => GitResult;
|
|
10
|
+
|
|
11
|
+
/** What a change touched against a base ref. */
|
|
12
|
+
export interface Changes {
|
|
13
|
+
/** The repository root. */
|
|
14
|
+
root: string;
|
|
15
|
+
/** The base ref, as given. */
|
|
16
|
+
base: string;
|
|
17
|
+
/** The merge base of the base ref and HEAD. */
|
|
18
|
+
mergeBase: string;
|
|
19
|
+
/** Each changed file, relative to the root, with the 1-based lines its new version added or changed. */
|
|
20
|
+
files: Map<string, Set<number>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Thrown when git cannot answer: no repository, an unknown base, a failed diff. */
|
|
24
|
+
export declare class ChangesUnreadable extends Error {
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Undo git's C-style quoting of a path. A path git did not quote is returned as it is.
|
|
30
|
+
*
|
|
31
|
+
* @param path A path as git printed it.
|
|
32
|
+
* @returns The path.
|
|
33
|
+
*/
|
|
34
|
+
export declare function unquoteGitPath(path: string): string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read the changed lines out of a zero-context unified diff.
|
|
38
|
+
*
|
|
39
|
+
* @param text The diff.
|
|
40
|
+
* @returns Each changed file with its changed lines.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseUnifiedDiff(text: string): Map<string, Set<number>>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The lines a change touched against a base ref: the diff from the merge base to the working tree,
|
|
46
|
+
* plus every untracked file git does not ignore.
|
|
47
|
+
*
|
|
48
|
+
* @param opts The base ref, a directory inside the repository, and the git runner.
|
|
49
|
+
* @returns What changed.
|
|
50
|
+
* @throws {ChangesUnreadable} When git cannot answer.
|
|
51
|
+
* @example
|
|
52
|
+
* ```js
|
|
53
|
+
* import { readChanges } from "@cosyte/script-utils/changes";
|
|
54
|
+
* const { files } = readChanges({ base: "origin/main" });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function readChanges(opts: { base: string; cwd?: string; git?: GitRunner }): Changes;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A file as it was at a commit. The path reaches git on standard input, never as an argument.
|
|
61
|
+
*
|
|
62
|
+
* @param opts The commit id as git printed it, the path relative to the repository root, a
|
|
63
|
+
* directory inside the repository, and the git runner.
|
|
64
|
+
* @returns The file's text, or `null` when the commit has no such path.
|
|
65
|
+
* @throws {ChangesUnreadable} When git cannot answer, or the path names no file.
|
|
66
|
+
* @example
|
|
67
|
+
* ```js
|
|
68
|
+
* import { readChanges, readFileAtCommit } from "@cosyte/script-utils/changes";
|
|
69
|
+
* const { mergeBase, root } = readChanges({ base: "origin/main" });
|
|
70
|
+
* const before = readFileAtCommit({ commit: mergeBase, path: "quality-floor.json", cwd: root });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function readFileAtCommit(opts: {
|
|
74
|
+
commit: string;
|
|
75
|
+
path: string;
|
|
76
|
+
cwd: string;
|
|
77
|
+
git?: GitRunner;
|
|
78
|
+
}): string | null;
|
package/changes.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// @cosyte/script-utils/changes
|
|
2
|
+
//
|
|
3
|
+
// WHAT A PULL REQUEST CHANGED, BY LINE, READ FROM GIT. The ratchet's rule for a pull request (its
|
|
4
|
+
// changed lines covered, every mutant in them killed) and the changed-files mutation run both need
|
|
5
|
+
// the same answer to "what did this change touch", so it is computed here, once.
|
|
6
|
+
//
|
|
7
|
+
// The answer is the diff from the merge base of the base ref and HEAD to the working tree, plus
|
|
8
|
+
// every untracked file that git does not ignore. In CI the working tree is HEAD, so this is exactly
|
|
9
|
+
// what the pull request changed. Locally it also counts edits not yet committed, which is what a
|
|
10
|
+
// person running the gate before pushing wants judged.
|
|
11
|
+
//
|
|
12
|
+
// A deleted file changes no lines that still exist, so it is left out. A file moved without edits
|
|
13
|
+
// changes none either (rename detection is switched on explicitly, so a user's `diff.renames`
|
|
14
|
+
// setting cannot change the answer). Zero dependencies, like the rest of this package.
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Thrown when git cannot answer: no repository, an unknown base, a failed diff. Exit 2. */
|
|
21
|
+
export class ChangesUnreadable extends Error {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} message What could not be read.
|
|
24
|
+
*/
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "ChangesUnreadable";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run git with array arguments, never through a shell. Not exported: every argument a caller
|
|
33
|
+
* could reach is a literal or an id git itself printed, and a value a caller passes in (a base
|
|
34
|
+
* ref, a path) reaches git on standard input, so no value can become an option such as
|
|
35
|
+
* `--upload-pack`, which runs a command.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]} args The arguments.
|
|
38
|
+
* @param {string} cwd The directory to run in.
|
|
39
|
+
* @param {string} [input] What to write to git's standard input.
|
|
40
|
+
* @returns {{ status: number | null, stdout: string, stderr: string }} The result.
|
|
41
|
+
*/
|
|
42
|
+
function runGit(args, cwd, input) {
|
|
43
|
+
const r = spawnSync("git", args, {
|
|
44
|
+
cwd,
|
|
45
|
+
input,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
48
|
+
});
|
|
49
|
+
// When git could not start, the status is null and nothing reads the output.
|
|
50
|
+
return {
|
|
51
|
+
status: r.error ? null : r.status,
|
|
52
|
+
stdout: r.stdout,
|
|
53
|
+
stderr: r.error ? r.error.message : r.stderr,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A commit id as git prints one: 40 hex digits, or 64 in a SHA-256 repository. */
|
|
58
|
+
const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
59
|
+
|
|
60
|
+
/** A control character: it would end or break the line git reads a name from. */
|
|
61
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run git, throwing {@link ChangesUnreadable} with git's own complaint when it fails.
|
|
65
|
+
*
|
|
66
|
+
* @param {typeof runGit} git Runs git.
|
|
67
|
+
* @param {string[]} args The arguments.
|
|
68
|
+
* @param {string} cwd The directory to run in.
|
|
69
|
+
* @param {string} what What could not be read, for the message.
|
|
70
|
+
* @param {string} [input] What to write to git's standard input.
|
|
71
|
+
* @returns {string} Git's standard output.
|
|
72
|
+
*/
|
|
73
|
+
function mustRun(git, args, cwd, what, input) {
|
|
74
|
+
const r = git(args, cwd, input);
|
|
75
|
+
if (r.status !== 0) {
|
|
76
|
+
throw new ChangesUnreadable(`${what}: git ${args.join(" ")} failed: ${r.stderr.trim()}`);
|
|
77
|
+
}
|
|
78
|
+
return r.stdout;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The escapes git writes in a quoted path besides octal bytes, as the byte each stands for. */
|
|
82
|
+
const GIT_ESCAPES = Object.freeze({ n: 10, t: 9, r: 13 });
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Undo git's C-style quoting of a path (`"a\"b"`, octal escapes for bytes). A path git did not
|
|
86
|
+
* quote is returned as it is.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} path A path as git printed it.
|
|
89
|
+
* @returns {string} The path.
|
|
90
|
+
*/
|
|
91
|
+
export function unquoteGitPath(path) {
|
|
92
|
+
if (!path.startsWith('"') || !path.endsWith('"') || path.length < 2) return path;
|
|
93
|
+
const body = path.slice(1, -1);
|
|
94
|
+
/** @type {number[]} */
|
|
95
|
+
const bytes = [];
|
|
96
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
97
|
+
const ch = body[i];
|
|
98
|
+
if (ch !== "\\") {
|
|
99
|
+
bytes.push(...Buffer.from(ch));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const next = body[i + 1];
|
|
103
|
+
const octal = /^[0-7]{3}/.exec(body.slice(i + 1));
|
|
104
|
+
if (octal !== null) {
|
|
105
|
+
bytes.push(Number.parseInt(octal[0], 8));
|
|
106
|
+
i += 3;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
bytes.push(GIT_ESCAPES[next] ?? next.charCodeAt(0));
|
|
110
|
+
i += 1;
|
|
111
|
+
}
|
|
112
|
+
return Buffer.from(bytes).toString("utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read the changed lines out of a zero-context unified diff (`git diff --unified=0`).
|
|
117
|
+
*
|
|
118
|
+
* @param {string} text The diff.
|
|
119
|
+
* @returns {Map<string, Set<number>>} Each changed file, relative to the repository root, with the
|
|
120
|
+
* 1-based line numbers its new version added or changed.
|
|
121
|
+
*/
|
|
122
|
+
export function parseUnifiedDiff(text) {
|
|
123
|
+
/** @type {Map<string, Set<number>>} */
|
|
124
|
+
const files = new Map();
|
|
125
|
+
/** @type {Set<number> | null} */
|
|
126
|
+
let current = null;
|
|
127
|
+
for (const line of text.split("\n")) {
|
|
128
|
+
if (line.startsWith("+++ ")) {
|
|
129
|
+
// git ends a name that has a space in it with a tab, for patch(1); the tab is not the name's.
|
|
130
|
+
const target = line.endsWith("\t") ? line.slice(4, -1) : line.slice(4);
|
|
131
|
+
if (target === "/dev/null") {
|
|
132
|
+
current = null;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// The destination prefix is pinned to b/ by the diff's own arguments, whatever the config says.
|
|
136
|
+
const path = unquoteGitPath(target).slice(2);
|
|
137
|
+
current = files.get(path) ?? new Set();
|
|
138
|
+
files.set(path, current);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
142
|
+
if (hunk !== null && current !== null) {
|
|
143
|
+
const start = Number(hunk[1]);
|
|
144
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
145
|
+
for (let n = start; n < start + count; n += 1) current.add(n);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return files;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The lines a change touched against a base ref.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} opts Options.
|
|
155
|
+
* @param {string} opts.base The base ref, for example `origin/main`.
|
|
156
|
+
* @param {string} [opts.cwd] Any directory inside the repository.
|
|
157
|
+
* @param {typeof runGit} [opts.git] Runs git; replaced in tests.
|
|
158
|
+
* @returns {{ root: string, base: string, mergeBase: string, files: Map<string, Set<number>> }}
|
|
159
|
+
* The repository root, the base as given, the merge base it resolved to, and each changed file
|
|
160
|
+
* (relative to the root) with its changed lines.
|
|
161
|
+
* @throws {ChangesUnreadable} When git cannot answer.
|
|
162
|
+
*/
|
|
163
|
+
export function readChanges({ base, cwd = process.cwd(), git = runGit }) {
|
|
164
|
+
if (typeof base !== "string" || base === "") {
|
|
165
|
+
throw new ChangesUnreadable("no base ref was given to compare against");
|
|
166
|
+
}
|
|
167
|
+
// The ref reaches git on standard input, never as an argument. A dash at its start still names
|
|
168
|
+
// no ref git would take, so it is refused as a mistake, and a control character would end the
|
|
169
|
+
// line git reads it from.
|
|
170
|
+
if (base.startsWith("-")) {
|
|
171
|
+
throw new ChangesUnreadable(`the base ref ${JSON.stringify(base)} starts with a dash`);
|
|
172
|
+
}
|
|
173
|
+
if (CONTROL.test(base)) {
|
|
174
|
+
throw new ChangesUnreadable(`the base ref ${JSON.stringify(base)} has a control character`);
|
|
175
|
+
}
|
|
176
|
+
const root = mustRun(
|
|
177
|
+
git,
|
|
178
|
+
["rev-parse", "--show-toplevel"],
|
|
179
|
+
cwd,
|
|
180
|
+
"not inside a git repository",
|
|
181
|
+
).trim();
|
|
182
|
+
// Everything after this runs at the root, so a run from a package directory sees the whole change.
|
|
183
|
+
const must = (args, what, input) => mustRun(git, args, root, what, input);
|
|
184
|
+
const resolved = must(
|
|
185
|
+
["cat-file", "--batch-check=%(objectname)"],
|
|
186
|
+
"could not resolve the base ref",
|
|
187
|
+
`${base}^{commit}\n`,
|
|
188
|
+
).trim();
|
|
189
|
+
if (!COMMIT_ID.test(resolved)) {
|
|
190
|
+
throw new ChangesUnreadable(
|
|
191
|
+
`the base ref ${JSON.stringify(base)} names no commit git can find`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const mergeBase = must(["merge-base", resolved, "HEAD"], `no merge base with ${base}`).trim();
|
|
195
|
+
const diff = must(
|
|
196
|
+
[
|
|
197
|
+
"-c",
|
|
198
|
+
"core.quotepath=off",
|
|
199
|
+
"diff",
|
|
200
|
+
"--unified=0",
|
|
201
|
+
"--no-color",
|
|
202
|
+
"--no-ext-diff",
|
|
203
|
+
"--find-renames",
|
|
204
|
+
"--src-prefix=a/",
|
|
205
|
+
"--dst-prefix=b/",
|
|
206
|
+
"--diff-filter=d",
|
|
207
|
+
mergeBase,
|
|
208
|
+
"--",
|
|
209
|
+
],
|
|
210
|
+
"could not diff against the merge base",
|
|
211
|
+
);
|
|
212
|
+
const files = parseUnifiedDiff(diff);
|
|
213
|
+
const untracked = must(
|
|
214
|
+
["-c", "core.quotepath=off", "ls-files", "--others", "--exclude-standard", "--full-name"],
|
|
215
|
+
"could not list untracked files",
|
|
216
|
+
);
|
|
217
|
+
// The listing ends with a newline, so its last entry is empty: that names the root itself, which
|
|
218
|
+
// reads as no file and is skipped like any path that cannot be read (a dangling link, a socket).
|
|
219
|
+
for (const raw of untracked.split("\n")) {
|
|
220
|
+
const path = unquoteGitPath(raw);
|
|
221
|
+
let text;
|
|
222
|
+
try {
|
|
223
|
+
text = String(readFileSync(join(root, path)));
|
|
224
|
+
} catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const count = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
228
|
+
files.set(path, new Set(Array.from({ length: count }, (_, i) => i + 1)));
|
|
229
|
+
}
|
|
230
|
+
return { root, base, mergeBase, files };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A file as it was at a commit: what the ratchet reads the floor file at the merge base with.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} opts Options.
|
|
237
|
+
* @param {string} opts.commit The commit id, as git printed it.
|
|
238
|
+
* @param {string} opts.path The file, relative to the repository root, with `/` separators.
|
|
239
|
+
* @param {string} opts.cwd Any directory inside the repository.
|
|
240
|
+
* @param {typeof runGit} [opts.git] Runs git; replaced in tests.
|
|
241
|
+
* @returns {string | null} The file's text, or `null` when the commit has no such path.
|
|
242
|
+
* @throws {ChangesUnreadable} When git cannot answer, or the path names no file.
|
|
243
|
+
*/
|
|
244
|
+
export function readFileAtCommit({ commit, path, cwd, git = runGit }) {
|
|
245
|
+
if (!COMMIT_ID.test(commit)) {
|
|
246
|
+
throw new ChangesUnreadable(`the commit ${JSON.stringify(commit)} is not a commit id`);
|
|
247
|
+
}
|
|
248
|
+
if (CONTROL.test(path)) {
|
|
249
|
+
throw new ChangesUnreadable(`the path ${JSON.stringify(path)} has a control character`);
|
|
250
|
+
}
|
|
251
|
+
// `cat-file --batch` reads the name from standard input and prints a header line, the content,
|
|
252
|
+
// and a newline; a name it cannot find prints only `<name> missing`.
|
|
253
|
+
const out = mustRun(
|
|
254
|
+
git,
|
|
255
|
+
["cat-file", "--batch"],
|
|
256
|
+
cwd,
|
|
257
|
+
`could not read ${path} at ${commit}`,
|
|
258
|
+
`${commit}:${path}\n`,
|
|
259
|
+
);
|
|
260
|
+
const end = out.indexOf("\n");
|
|
261
|
+
const header = end === -1 ? out : out.slice(0, end);
|
|
262
|
+
if (header === `${commit}:${path} missing`) return null;
|
|
263
|
+
const parts = header.split(" ");
|
|
264
|
+
if (parts.length !== 3 || !COMMIT_ID.test(parts[0])) {
|
|
265
|
+
throw new ChangesUnreadable(`${path} at ${commit} could not be read: ${header}`);
|
|
266
|
+
}
|
|
267
|
+
if (parts[1] !== "blob") throw new ChangesUnreadable(`${path} at ${commit} is not a file`);
|
|
268
|
+
return out.slice(end + 1, -1);
|
|
269
|
+
}
|