@cosyte/script-utils 0.1.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosyte/script-utils",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
5
5
  "keywords": [
6
6
  "cli",
@@ -10,7 +10,9 @@
10
10
  "phi",
11
11
  "readme",
12
12
  "scripts",
13
- "cosyte"
13
+ "cosyte",
14
+ "coverage",
15
+ "mutation-testing"
14
16
  ],
15
17
  "homepage": "https://github.com/cosyte/config/tree/main/packages/script-utils#readme",
16
18
  "repository": {
@@ -31,7 +33,9 @@
31
33
  "cosyte-readme": "bin/cosyte-readme.js",
32
34
  "cosyte-claude-card": "bin/cosyte-claude-card.js",
33
35
  "cosyte-agent-notes": "bin/cosyte-agent-notes.js",
34
- "cosyte-catalog": "bin/cosyte-catalog.js"
36
+ "cosyte-catalog": "bin/cosyte-catalog.js",
37
+ "cosyte-ratchet": "bin/cosyte-ratchet.js",
38
+ "cosyte-docs-content": "bin/cosyte-docs-content.js"
35
39
  },
36
40
  "types": "index.d.ts",
37
41
  "exports": {
@@ -66,6 +70,18 @@
66
70
  "./agent-notes": {
67
71
  "types": "./agent-notes.d.ts",
68
72
  "default": "./agent-notes.js"
73
+ },
74
+ "./ratchet": {
75
+ "types": "./ratchet.d.ts",
76
+ "default": "./ratchet.js"
77
+ },
78
+ "./changes": {
79
+ "types": "./changes.d.ts",
80
+ "default": "./changes.js"
81
+ },
82
+ "./docs-content": {
83
+ "types": "./docs-content.d.ts",
84
+ "default": "./docs-content.js"
69
85
  }
70
86
  },
71
87
  "files": [
@@ -86,6 +102,12 @@
86
102
  "catalog.json",
87
103
  "agent-notes.js",
88
104
  "agent-notes.d.ts",
105
+ "ratchet.js",
106
+ "ratchet.d.ts",
107
+ "changes.js",
108
+ "changes.d.ts",
109
+ "docs-content.js",
110
+ "docs-content.d.ts",
89
111
  "check-no-emdash.sh",
90
112
  "bin",
91
113
  "README.md",
@@ -98,10 +120,11 @@
98
120
  "access": "public"
99
121
  },
100
122
  "devDependencies": {
101
- "vitest": "4.1.12"
123
+ "vitest": "5.1.0",
124
+ "@vitest/coverage-v8": "5.1.0"
102
125
  },
103
126
  "scripts": {
104
127
  "build": "node ../../scripts/pack-emdash-gate.mjs",
105
- "test": "vitest run"
128
+ "test": "vitest run --coverage"
106
129
  }
107
130
  }
package/phi-scan.d.ts CHANGED
@@ -293,7 +293,10 @@ export declare function readPhiExclusions(
293
293
  * 0 clean, 1 hits, 2 refuse, the whole repository as the scan root, the `--staged` route reading
294
294
  * everything but Markdown, and `scripts/phi-scan.exclude`. The cross-cutting floor only: a
295
295
  * repository that needs field-level detectors keeps a caller passing `detect`.
296
- * `node node_modules/@cosyte/script-utils/phi-scan.js [--staged | <path>...]`.
296
+ * `--allow-list <path>` names the allow list to use instead of `scripts/phi-allow-list.txt`
297
+ * (resolved against the working directory, replacing the repository's list); a path that does not
298
+ * exist or cannot be read refuses at 2.
299
+ * `node node_modules/@cosyte/script-utils/phi-scan.js [--allow-list <path>] [--staged | <path>...]`.
297
300
  */
298
301
  export declare function runPhiScanCli(argv: string[]): number;
299
302
 
package/phi-scan.js CHANGED
@@ -2102,12 +2102,13 @@ function gitModeKind(mode) {
2102
2102
 
2103
2103
  // ---- The command-line form (ST34) ---------------------------------------------------------------
2104
2104
  //
2105
- // `node node_modules/@cosyte/script-utils/phi-scan.js [--staged | <path>...]` runs this engine over
2106
- // the repository in the working directory with the house settings and no caller script: exit codes
2107
- // 0 clean, 1 hits, 2 refuse; the whole repository as the one scan root; the `--staged` route reading
2108
- // everything but Markdown (`exemptsMarkdown`); the allow-list at `scripts/phi-allow-list.txt` and the
2109
- // override log at `phi-scan-overrides.md`, the engine's defaults; and what the repository leaves
2110
- // out, in `scripts/phi-scan.exclude`.
2105
+ // `node node_modules/@cosyte/script-utils/phi-scan.js [--allow-list <path>] [--staged | <path>...]`
2106
+ // runs this engine over the repository in the working directory with the house settings and no
2107
+ // caller script: exit codes 0 clean, 1 hits, 2 refuse; the whole repository as the one scan root;
2108
+ // the `--staged` route reading everything but Markdown (`exemptsMarkdown`); the allow-list at
2109
+ // `scripts/phi-allow-list.txt` and the override log at `phi-scan-overrides.md`, the engine's
2110
+ // defaults; and what the repository leaves out, in `scripts/phi-scan.exclude`. `--allow-list
2111
+ // <path>` names another allow list in place of the repository's (see `runPhiScanCli`).
2111
2112
  //
2112
2113
  // AN EXCLUSION IS ONE LITERAL TRACKED PATH, as `excludedPaths` requires: no pattern, no directory,
2113
2114
  // because a class quietly grows new members. One per line; a line whose first character is `#` is a
@@ -2150,14 +2151,90 @@ export function readPhiExclusions(text, tracked) {
2150
2151
  return { paths, problems };
2151
2152
  }
2152
2153
 
2154
+ /**
2155
+ * Take `--allow-list <path>` out of the command line, leaving every other argument in its place.
2156
+ *
2157
+ * IT READS THE ENGINE'S OWN GRAMMAR, NOT A SIMPLER ONE, so it cannot take an argument the engine
2158
+ * would have read as something else: everything after `--` is a path, and the word after
2159
+ * `--allow-fixture` is that bypass's path, even when it reads `--allow-list`.
2160
+ *
2161
+ * @param {string[]} argv
2162
+ * @returns {{ argv: string[], allowList: string | undefined, problem: string | undefined }}
2163
+ */
2164
+ function takeAllowListOption(argv) {
2165
+ /** @type {string[]} */
2166
+ const rest = [];
2167
+ /** @type {string | undefined} */
2168
+ let allowList;
2169
+ let i = 0;
2170
+ while (i < argv.length) {
2171
+ const a = argv[i];
2172
+ if (a === "--") {
2173
+ rest.push(...argv.slice(i));
2174
+ break;
2175
+ }
2176
+ if (a === "--allow-fixture") {
2177
+ rest.push(...argv.slice(i, i + 2));
2178
+ i += 2;
2179
+ continue;
2180
+ }
2181
+ if (a === "--allow-list") {
2182
+ const value = argv[i + 1];
2183
+ if (value === undefined || value === "") {
2184
+ return { argv, allowList: undefined, problem: "--allow-list requires a path argument" };
2185
+ }
2186
+ if (allowList !== undefined) {
2187
+ return { argv, allowList: undefined, problem: "--allow-list was given more than once" };
2188
+ }
2189
+ allowList = value;
2190
+ i += 2;
2191
+ continue;
2192
+ }
2193
+ if (a !== undefined) rest.push(a);
2194
+ i += 1;
2195
+ }
2196
+ return { argv: rest, allowList, problem: undefined };
2197
+ }
2198
+
2153
2199
  /**
2154
2200
  * Run the engine from the command line over the repository in the working directory.
2155
2201
  *
2156
- * @param {string[]} argv The engine's own arguments: `--staged`, paths, `--allow-fixture <path>`.
2202
+ * `--allow-list <path>` NAMES THE ALLOW LIST INSTEAD OF `scripts/phi-allow-list.txt`, resolved
2203
+ * against the working directory. It is what a tool scanning a repository that keeps no list of its
2204
+ * own passes, usually an empty file: the run is then a scan with no exceptions, where without the
2205
+ * option it refuses for the missing list. The named file REPLACES the repository's list rather than
2206
+ * adding to it. A path that does not exist, or that exists and cannot be read (a directory, no read
2207
+ * permission), refuses at exit 2 before anything is scanned and says which: the engine itself would
2208
+ * let an unreadable list escape to node's own exit 1, the code reserved for hits. Without the option
2209
+ * nothing here changes.
2210
+ *
2211
+ * @param {string[]} argv The engine's own arguments: `--staged`, paths, `--allow-fixture <path>`,
2212
+ * and this form's `--allow-list <path>`.
2157
2213
  * @returns {number} 0 clean, 1 hits, 2 the scan could not account for something.
2158
2214
  */
2159
2215
  export function runPhiScanCli(argv) {
2160
2216
  const repoRoot = process.cwd();
2217
+ const option = takeAllowListOption(argv);
2218
+ if (option.problem !== undefined) {
2219
+ process.stderr.write(`[phi-scan] ${option.problem}\n`);
2220
+ return CLI_EXIT_CODES.refuse;
2221
+ }
2222
+ /** @type {string | undefined} */
2223
+ let allowListPath;
2224
+ if (option.allowList !== undefined) {
2225
+ allowListPath = resolve(repoRoot, option.allowList);
2226
+ try {
2227
+ readFileSync(allowListPath, "utf8");
2228
+ } catch (err) {
2229
+ const code = err instanceof Error && "code" in err ? String(err.code) : "";
2230
+ process.stderr.write(
2231
+ code === "ENOENT"
2232
+ ? `[phi-scan] the allow list --allow-list names does not exist: ${allowListPath}\n`
2233
+ : `[phi-scan] the allow list --allow-list names could not be read (${code || "unknown error"}): ${allowListPath}\n`,
2234
+ );
2235
+ return CLI_EXIT_CODES.refuse;
2236
+ }
2237
+ }
2161
2238
  let excludedPaths = new Set();
2162
2239
  const file = join(repoRoot, ...PHI_EXCLUDE_PATH.split("/"));
2163
2240
  if (existsSync(file)) {
@@ -2196,7 +2273,8 @@ export function runPhiScanCli(argv) {
2196
2273
  isStagedReadable: exemptsMarkdown,
2197
2274
  excludedPaths,
2198
2275
  repoRoot,
2199
- argv,
2276
+ argv: option.argv,
2277
+ ...(allowListPath === undefined ? {} : { allowListPath }),
2200
2278
  });
2201
2279
  } catch (err) {
2202
2280
  // The backstop: an error the engine did not raise as a refusal must still not take node's own
package/ratchet.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { GitRunner } from "./changes.js";
2
+
3
+ /** The floor file's name, at a repository's root. */
4
+ export declare const FLOOR_FILE: "quality-floor.json";
5
+
6
+ /** The four coverage metrics, in the order they print. */
7
+ export declare const COVERAGE_METRICS: readonly ["lines", "branches", "functions", "statements"];
8
+
9
+ /** Where each report is read from, relative to a package's directory. */
10
+ export declare const REPORTS: Readonly<{
11
+ summary: "coverage/coverage-summary.json";
12
+ lcov: "coverage/lcov.info";
13
+ mutation: "reports/mutation/mutation.json";
14
+ changed: "reports/mutation/changed.json";
15
+ }>;
16
+
17
+ /** One package's floors. */
18
+ export interface FloorEntry {
19
+ coverage?: Partial<Record<"lines" | "branches" | "functions" | "statements", number>>;
20
+ mutation?: number;
21
+ }
22
+
23
+ /** A floor file: each package directory, relative to the file, with its floors. */
24
+ export interface Floor {
25
+ $comment?: string;
26
+ packages: Record<string, FloorEntry>;
27
+ }
28
+
29
+ /** An inline exception found in a source file. */
30
+ export interface ExceptionComment {
31
+ line: number;
32
+ tool: "coverage" | "mutation";
33
+ directive: string;
34
+ reason: string | null;
35
+ }
36
+
37
+ /** What {@link judgeChangedCode} found in a change's own code. */
38
+ export interface ChangedCodeVerdict {
39
+ uncovered: { file: string; line: number }[];
40
+ survived: { file: string; line: number; mutator: string; status: string }[];
41
+ exceptions: { file: string; line: number; tool: string; what: string; reason: string | null }[];
42
+ unmutated: string[];
43
+ }
44
+
45
+ /** Thrown when an input cannot be read or is not what it must be. */
46
+ export declare class RatchetUnreadable extends Error {
47
+ constructor(message: string);
48
+ }
49
+
50
+ /** Check a parsed floor file's shape; every key must be known. */
51
+ export declare function parseFloor(value: unknown): Floor;
52
+
53
+ /**
54
+ * Compare a floor file with the one it replaces: each floor the earlier one held that is now lower
55
+ * (`after` the lower value) or gone (`after` null). Raised and new floors are not listed.
56
+ */
57
+ export declare function compareFloors(
58
+ before: Floor,
59
+ after: Floor,
60
+ ): { dir: string; metric: string; before: number; after: number | null }[];
61
+
62
+ /** Cut a percentage to two decimals, toward zero. */
63
+ export declare function toFloor(value: number): number;
64
+
65
+ /** Read the four coverage percentages from Istanbul's `json-summary` report. */
66
+ export declare function readCoverageSummary(path: string): Record<string, number>;
67
+
68
+ /** Count a Stryker JSON report's mutants by status and work out its mutation score. */
69
+ export declare function mutationScore(
70
+ report: unknown,
71
+ path: string,
72
+ ): { score: number; counts: Record<string, number> };
73
+
74
+ /** Read lcov's line records: absolute file path to line to hits. */
75
+ export declare function parseLcov(text: string, baseDir: string): Map<string, Map<number, number>>;
76
+
77
+ /** Find the inline coverage hints and Stryker directives in a source file. */
78
+ export declare function findExceptionComments(text: string): ExceptionComment[];
79
+
80
+ /** The first and last line a coverage hint takes out of the count. */
81
+ export declare function hintRange(
82
+ hint: { line: number; directive: string },
83
+ lines: string[],
84
+ counted: Map<number, number> | undefined,
85
+ ): [number, number];
86
+
87
+ /** Judge a change's own code: its changed lines coverage counts, and the mutants in them. */
88
+ export declare function judgeChangedCode(input: {
89
+ changed: Map<string, Set<number>>;
90
+ lines: Map<string, Map<number, number>>;
91
+ mutants: Map<string, unknown[]> | null;
92
+ read: (file: string) => string;
93
+ }): ChangedCodeVerdict;
94
+
95
+ /** Read a changed-files mutation report into absolute file path to mutants. */
96
+ export declare function readMutants(path: string, packageDir: string): Map<string, unknown[]>;
97
+
98
+ /** Parse the ratchet's command line. */
99
+ export declare function parseArgs(argv: string[]): {
100
+ command: "check" | "raise";
101
+ floor: string;
102
+ base: string | null;
103
+ skip: Set<"coverage" | "mutation">;
104
+ };
105
+
106
+ /**
107
+ * Run the ratchet and return its exit code: 0 nothing dropped and changed code is reached, 1 a
108
+ * drop or unreached changed code, 2 an input could not be read.
109
+ *
110
+ * @example
111
+ * ```js
112
+ * import { runRatchet } from "@cosyte/script-utils/ratchet";
113
+ * process.exitCode = runRatchet({
114
+ * argv: ["check", "--base", "origin/main"],
115
+ * cwd: process.cwd(),
116
+ * out: (text) => process.stdout.write(text),
117
+ * err: (text) => process.stderr.write(text),
118
+ * });
119
+ * ```
120
+ */
121
+ export declare function runRatchet(opts: {
122
+ argv: string[];
123
+ cwd: string;
124
+ out: (text: string) => void;
125
+ err: (text: string) => void;
126
+ git?: GitRunner;
127
+ }): number;