speccle 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Alton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # speccle
2
+
3
+ The deterministic tooling the skills invoke: one bin, three commands —
4
+
5
+ ```
6
+ speccle lint # enforce the convention over a repo's specs
7
+ speccle claims # join criteria to the test names that claim them
8
+ speccle strength # oracle-strength heatmap: per-criterion killed ÷ covered
9
+ speccle strength init # provision the strength stack into a target
10
+ ```
11
+
12
+ - `lint` — enforce the [convention](https://github.com/matthewalton/speccle/blob/main/docs/convention.md) over a repo's specs.
13
+ - `claims` — join every criterion to the test names carrying its id, statically. No
14
+ reports needed, so it is cheap enough to gate on.
15
+ - `strength` — join specs + Stryker mutation report + coverage into per-criterion
16
+ `killed ÷ covered`.
17
+ - `strength init` — the setup `strength` measures against: install the stack's
18
+ devDependencies and write the preset configs.
19
+
20
+ The bin is named after the package; every command is an explicit subcommand (a bare
21
+ invocation is a usage error, exit code 2). `strength` names the measurement — oracle
22
+ strength — not the heatmap rendering of it.
23
+
24
+ Everything here is a **Speccle tool**: deterministic, independently runnable, emits
25
+ typed JSON, never calls an LLM (see [CONTEXT.md](https://github.com/matthewalton/speccle/blob/main/CONTEXT.md)).
26
+
27
+ ## lint
28
+
29
+ ```sh
30
+ speccle lint [path] [--json]
31
+ ```
32
+
33
+ Lints every `SPEC.md` under `path` (default: current directory; a file path lints just
34
+ that file) against the nine fixed rules in
35
+ [docs/convention.md](https://github.com/matthewalton/speccle/blob/main/docs/convention.md) — six structural, three quality
36
+ heuristics judging the heading statement only. One severity, no configuration.
37
+
38
+ Output is human terminal text by default; `--json` emits the typed `LintReport`
39
+ (see [`src/lint.ts`](src/lint.ts)) — the contract other tooling consumes. Exit codes:
40
+ `0` clean, `1` violations, `2` usage error.
41
+
42
+ ## claims
43
+
44
+ ```sh
45
+ speccle claims [path] [--json] [--dialect <name>]
46
+ ```
47
+
48
+ Joins every criterion under `path` to the test names that claim it, read statically from
49
+ the test files — no mutation or coverage reports, so it runs in seconds. A criterion no
50
+ test name claims is **unclaimed**; a token claiming a criterion no spec declares is an
51
+ **unknown claim**. Only test files under a spec's own folder count, so unrelated tooling
52
+ tests can never phantom-claim.
53
+
54
+ `--dialect` names the **test dialect**: which files are tests, and how a test's full name
55
+ is read.
56
+
57
+ | dialect | test files | names read |
58
+ | --------------------- | --------------------------------------- | ---------------------------------------------------------------------- |
59
+ | `ts-vitest` (default) | `*.test.*`, `*.spec.*` | `describe` / `it` / `test` titles |
60
+ | `swift` | `*Tests.swift`, anything under `Tests/` | `@Test("…")` / `@Suite("…")` display names, `func test…()` identifiers |
61
+
62
+ Dialects are named and owned by speccle — a repo declares which one it is on,
63
+ never how that dialect works, so a clean run means the same thing in every repo. An
64
+ unsupported stack is a usage error, not a silent empty result. Where a framework gives a
65
+ test no string name, the criterion id takes its identifier-safe spelling:
66
+ `func test_CHECKOUT_1_taxRounds()` claims `CHECKOUT-1`. Reports always render the
67
+ bracketed form.
68
+
69
+ Names are read statically, so a name built dynamically shows up as unclaimed — the
70
+ failure mode is a false alarm, never a silent pass. `--json` emits the typed
71
+ `ClaimsReport` (see [`src/claims.ts`](src/claims.ts)). Exit codes: `0` every criterion
72
+ claimed and no unknown claims, `1` otherwise, `2` usage error.
73
+
74
+ ## strength
75
+
76
+ ```sh
77
+ speccle strength [path] [--json] [--mutation <file>] [--coverage <file>]
78
+ ```
79
+
80
+ Joins three inputs into one number per **acceptance criterion**: the `SPEC.md` files under
81
+ `path`, a StrykerJS mutation report, and an Istanbul `json-summary`. A test claims a
82
+ criterion by carrying its `[KEY-n]` token anywhere in its full concatenated name, describe
83
+ titles included.
84
+
85
+ ```
86
+ features/checkout/SPEC.md
87
+ CHECKOUT-1 ████████████████████ 100.0% 14/14 Tax rounds half-up to 2dp per line item
88
+ CHECKOUT-2 ████████████████████ 100.0% 7/7 An empty basket totals zero
89
+ CHECKOUT-3 ██████████████████░░ 88.2% 15/17 Checkout rejects a basket of more than 100 line items
90
+ features/checkout/checkout.ts:13:11 StringLiteral → ``
91
+ features/checkout/checkout.ts:14:17 StringLiteral → ""
92
+ line coverage 100.0%
93
+
94
+ oracle strength 95.7% (44/46) line coverage 100.0%
95
+ 2 surviving mutants — each one a change no test noticed
96
+ ```
97
+
98
+ **Oracle strength** is `killed ÷ covered` — of the mutants a criterion's tests execute, the
99
+ fraction the suite kills. A kill counts for every criterion covering that mutant, not only
100
+ the one whose test detected it, so
101
+ **a criterion below 100% always has at least one surviving mutant listed beneath it** —
102
+ the exact code change no test noticed, which is what `strengthen` routes on. Line coverage
103
+ sits alongside as the naïve baseline, precisely so the gap between them is visible.
104
+
105
+ The command reads reports; it never runs Stryker. Defaults are `reports/mutation/mutation.json`
106
+ and `coverage/coverage-summary.json`, relative to `path`. Mutants Stryker never ran
107
+ (`NoCoverage`) or could not run (`CompileError`, `RuntimeError`, `Ignored`, `Pending`) are
108
+ excluded from both sides of the ratio.
109
+
110
+ The target project must run Stryker with `coverageAnalysis: "perTest"` — without it the
111
+ report carries no `coveredBy`, and `strength` refuses rather than guessing. A criterion no
112
+ test claims is reported as **unclaimed**, not as zero strength; tokens claiming a criterion
113
+ no spec declares are reported too.
114
+
115
+ `--json` emits the typed `StrengthReport` (see [`src/strength.ts`](src/strength.ts)). The
116
+ command exits `0` whenever it produced a report — judging a diff against a threshold is a
117
+ separate concern.
118
+
119
+ ## strength init
120
+
121
+ ```sh
122
+ speccle strength init [path] [--json] [--skip-install] [--mutate <glob>]...
123
+ ```
124
+
125
+ Provisions the stack `strength` measures against — the explicit command the
126
+ `strengthen` skill offers when a target is missing pieces,
127
+ instead of a hand-assembled config recipe. In one run it:
128
+
129
+ - installs the missing devDependencies — `speccle` itself, caret-pinned to the
130
+ running oracle's own version, plus the stack pinned to the majors the join is proven
131
+ on (`vitest@^4`, `@vitest/coverage-istanbul@^4`, `@stryker-mutator/core@^9`,
132
+ `@stryker-mutator/vitest-runner@^9`) — using the package manager the target's
133
+ lockfile names (pnpm / npm / yarn / bun);
134
+ - writes `stryker.config.json` with the load-bearing preset fields —
135
+ `coverageAnalysis: "perTest"` and the `json` reporter at
136
+ `reports/mutation/mutation.json` (the paths `strength` reads by default) — and mutate
137
+ globs derived from the `SPEC.md` folders under `path` (no specs yet →
138
+ `features/**/*.ts`; override with `--mutate`, repeatable);
139
+ - writes a `vitest.config.ts` with the istanbul provider and `json-summary` reporter.
140
+
141
+ Init also warns (best-effort, via `~/.claude/settings.json`) when the target vendors
142
+ the speccle skills project-level in `.claude/skills/` while a user-level speccle plugin
143
+ is still enabled — two copies of every skill would load.
144
+
145
+ An existing Stryker or vitest/vite config is **kept, never overwritten** — init reports
146
+ it and names the fields it must carry itself. The command is idempotent: re-running
147
+ changes nothing that is already in place. `--skip-install` reports the install command
148
+ instead of running it; `--json` emits the typed `InitReport` (see
149
+ [`src/init.ts`](src/init.ts)). Running init at a target's root **is** the consent to
150
+ write there — there is no postinstall hook or implicit trigger. Exit codes: `0` done,
151
+ `2` usage error (including a `path` with no `package.json`).
152
+
153
+ After init, the loop is the standard one:
154
+
155
+ ```sh
156
+ npx vitest run --coverage # → coverage/coverage-summary.json
157
+ npx stryker run # → reports/mutation/mutation.json
158
+ speccle strength .
159
+ ```
160
+
161
+ Repo-specific blind spots stay the target's decision: edit the written config's `mutate`
162
+ globs to exclude what mutation can't reach (e.g. entry files only exercised through
163
+ child processes).
164
+
165
+ ## Development
166
+
167
+ TypeScript ESM, zero runtime dependencies. Node ≥ 24 runs the sources directly:
168
+
169
+ ```sh
170
+ node src/cli.ts lint ../../targets/checkout # no build needed
171
+ pnpm test # vitest: unit + e2e
172
+ pnpm build # tsc → dist/ (what the bin points at)
173
+ ```
174
+
175
+ Spec parsing lives in [`src/spec.ts`](src/spec.ts), written once and shared by lint
176
+ and the heatmap. The [toy target project](https://github.com/matthewalton/speccle/blob/main/targets/checkout) is the clean
177
+ proving ground; the dirty regression fixtures live in
178
+ [`test/fixtures`](test/fixtures).
package/dist/check.js ADDED
@@ -0,0 +1,74 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { discoverFiles, discoverSpecs } from "./discover.js";
4
+ import { DEFAULT_COVERAGE_SUMMARY, DEFAULT_MUTATION_REPORT } from "./strength.js";
5
+ export async function check(target, options = {}) {
6
+ const root = resolve(target);
7
+ if (!(await isDirectory(root)))
8
+ throw new Error(`path not found: ${target}`);
9
+ const mutationPath = resolve(root, options.mutationReport ?? DEFAULT_MUTATION_REPORT);
10
+ const coveragePath = resolve(root, options.coverageSummary ?? DEFAULT_COVERAGE_SUMMARY);
11
+ const markerPath = join(dirname(mutationPath), ".speccle-evaluated");
12
+ const newest = await newestSliceFile(root, new Set([mutationPath, coveragePath, markerPath]));
13
+ const mutation = await checkReport(root, mutationPath, newest);
14
+ const coverage = await checkReport(root, coveragePath, newest);
15
+ const markerTime = await mtime(markerPath);
16
+ const mutationTime = await mtime(mutationPath);
17
+ const coverageTime = await mtime(coveragePath);
18
+ const evaluated = markerTime !== undefined &&
19
+ mutationTime !== undefined &&
20
+ coverageTime !== undefined &&
21
+ markerTime >= mutationTime &&
22
+ markerTime >= coverageTime;
23
+ return { root, mutation, coverage, evaluated, marker: rel(root, markerPath) };
24
+ }
25
+ /** The newest file in any spec's folder subtree — what a report must post-date. */
26
+ async function newestSliceFile(root, exclude) {
27
+ const folders = new Set((await discoverSpecs(root)).map((spec) => dirname(spec)));
28
+ const seen = new Set();
29
+ let newest;
30
+ for (const folder of folders) {
31
+ const abs = folder === "." ? root : join(root, folder);
32
+ for (const file of await discoverFiles(abs, () => true)) {
33
+ const relPath = folder === "." ? file : `${folder}/${file}`;
34
+ const absPath = join(abs, file);
35
+ if (seen.has(relPath) || exclude.has(absPath))
36
+ continue;
37
+ seen.add(relPath);
38
+ const mtimeMs = await mtime(absPath);
39
+ if (mtimeMs === undefined)
40
+ continue;
41
+ if (newest === undefined || mtimeMs > newest.mtimeMs)
42
+ newest = { file: relPath, mtimeMs };
43
+ }
44
+ }
45
+ return newest;
46
+ }
47
+ async function checkReport(root, path, newest) {
48
+ const reportTime = await mtime(path);
49
+ if (reportTime === undefined)
50
+ return { path: rel(root, path), status: "missing" };
51
+ if (newest !== undefined && newest.mtimeMs > reportTime) {
52
+ return { path: rel(root, path), status: "stale", staleAgainst: newest.file };
53
+ }
54
+ return { path: rel(root, path), status: "fresh" };
55
+ }
56
+ async function mtime(path) {
57
+ try {
58
+ return (await stat(path)).mtimeMs;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ async function isDirectory(path) {
65
+ try {
66
+ return (await stat(path)).isDirectory();
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ function rel(root, path) {
73
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
74
+ }
package/dist/claims.js ADDED
@@ -0,0 +1,80 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { DEFAULT_DIALECT, resolveDialect } from "./dialects.js";
4
+ import { discoverSpecs, discoverTests } from "./discover.js";
5
+ import { compareCriterionIds, parseSpec, readClaimedIds } from "./spec.js";
6
+ export async function claims(target, options = {}) {
7
+ const dialect = resolveDialect(options.dialect ?? DEFAULT_DIALECT);
8
+ const root = resolve(target);
9
+ if (!(await isDirectory(root)))
10
+ throw new Error(`path not found: ${target}`);
11
+ const specFiles = await discoverSpecs(root);
12
+ const specs = await Promise.all(specFiles.map(async (file) => parseSpec(await readFile(join(root, file), "utf8"), file)));
13
+ const criteria = new Map();
14
+ for (const spec of specs) {
15
+ for (const criterion of spec.criteria) {
16
+ if (criterion.wellFormed && !criteria.has(criterion.id)) {
17
+ criteria.set(criterion.id, { statement: criterion.statement, spec: spec.file });
18
+ }
19
+ }
20
+ }
21
+ // A slice's tests live in its own folder: only test files under a spec's folder
22
+ // count, so unrelated tooling tests can never claim (or phantom-claim) a criterion.
23
+ const folders = [...new Set(specFiles.map((file) => dirname(file)))];
24
+ const found = new Set();
25
+ for (const folder of folders) {
26
+ const abs = folder === "." ? root : join(root, folder);
27
+ for (const file of await discoverTests(abs, dialect)) {
28
+ found.add(folder === "." ? file : `${folder}/${file}`);
29
+ }
30
+ }
31
+ const testFiles = [...found].sort();
32
+ const claimsById = new Map();
33
+ for (const file of testFiles) {
34
+ const source = await readFile(join(root, file), "utf8");
35
+ for (const { name, spelling } of dialect.readTestNames(source)) {
36
+ for (const id of readClaimedIds(name, spelling)) {
37
+ const entry = claimsById.get(id) ?? [];
38
+ entry.push({ file, name });
39
+ claimsById.set(id, entry);
40
+ }
41
+ }
42
+ }
43
+ const features = specs.map((spec) => ({
44
+ key: spec.key?.raw,
45
+ spec: spec.file,
46
+ criteria: [...criteria.entries()]
47
+ .filter(([, value]) => value.spec === spec.file)
48
+ .map(([id, value]) => ({
49
+ id,
50
+ statement: value.statement,
51
+ claimed: claimsById.has(id),
52
+ tests: claimsById.get(id) ?? [],
53
+ }))
54
+ .sort((a, b) => compareCriterionIds(a.id, b.id)),
55
+ }));
56
+ const unclaimed = [...criteria.keys()]
57
+ .filter((id) => !claimsById.has(id))
58
+ .sort(compareCriterionIds);
59
+ const unknownClaims = [...claimsById.entries()]
60
+ .filter(([id]) => !criteria.has(id))
61
+ .map(([id, tests]) => ({ id, tests }))
62
+ .sort((a, b) => compareCriterionIds(a.id, b.id));
63
+ return {
64
+ root,
65
+ dialect: dialect.name,
66
+ testFiles,
67
+ features,
68
+ unclaimed,
69
+ unknownClaims,
70
+ clean: unclaimed.length === 0 && unknownClaims.length === 0,
71
+ };
72
+ }
73
+ async function isDirectory(path) {
74
+ try {
75
+ return (await stat(path)).isDirectory();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ import { check } from "./check.js";
3
+ import { claims } from "./claims.js";
4
+ import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
5
+ import { init } from "./init.js";
6
+ import { lint } from "./lint.js";
7
+ import { renderCheck, renderClaims, renderHuman, renderInit, renderStrength } from "./render.js";
8
+ import { DEFAULT_COVERAGE_SUMMARY, DEFAULT_MUTATION_REPORT, strength } from "./strength.js";
9
+ const USAGE = `Usage: speccle <command> [options]
10
+
11
+ Commands:
12
+ lint [path] [--json] Lint every SPEC.md under path (default: current directory)
13
+ claims [path] [--json] Join criteria to the test names that claim them — no reports needed
14
+ strength [path] [--json] Oracle-strength heatmap: per-criterion killed ÷ covered
15
+ strength init [path] [--json] Provision the strength stack: devDependencies + configs
16
+
17
+ claims options:
18
+ --dialect <name> Test dialect: ${DIALECT_NAMES.join(", ")} (default: ${DEFAULT_DIALECT})
19
+
20
+ strength options:
21
+ --check Report whether the reports are fresh, stale, or missing — never runs them
22
+ --mutation <file> Stryker JSON report (default: ${DEFAULT_MUTATION_REPORT})
23
+ --coverage <file> Istanbul json-summary (default: ${DEFAULT_COVERAGE_SUMMARY})
24
+
25
+ strength init options:
26
+ --mutate <glob> Mutate glob for the Stryker config, repeatable
27
+ (default: derived from the SPEC.md folders under path)
28
+ --skip-install Report the install command instead of running it
29
+
30
+ Exit codes: 0 clean, 1 violations, 2 usage error`;
31
+ async function main(argv) {
32
+ const [command, ...rest] = argv;
33
+ if (command === "lint")
34
+ return runLint(rest);
35
+ if (command === "claims")
36
+ return runClaims(rest);
37
+ if (command === "strength" && rest[0] === "init")
38
+ return runInit(rest.slice(1));
39
+ if (command === "strength")
40
+ return runStrength(rest);
41
+ console.error(USAGE);
42
+ return 2;
43
+ }
44
+ async function runClaims(args) {
45
+ let json = false;
46
+ let dialect;
47
+ const positional = [];
48
+ for (let i = 0; i < args.length; i++) {
49
+ const arg = args[i];
50
+ if (arg === "--json")
51
+ json = true;
52
+ else if (arg === "--dialect") {
53
+ const value = args[++i];
54
+ if (value === undefined) {
55
+ console.error(`--dialect needs a dialect name\n\n${USAGE}`);
56
+ return 2;
57
+ }
58
+ dialect = value;
59
+ }
60
+ else if (arg.startsWith("-")) {
61
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
62
+ return 2;
63
+ }
64
+ else
65
+ positional.push(arg);
66
+ }
67
+ if (positional.length > 1) {
68
+ console.error(`claims takes at most one path\n\n${USAGE}`);
69
+ return 2;
70
+ }
71
+ let report;
72
+ try {
73
+ report = await claims(positional[0] ?? ".", { ...(dialect !== undefined && { dialect }) });
74
+ }
75
+ catch (err) {
76
+ console.error(message(err));
77
+ return 2;
78
+ }
79
+ console.log(json ? JSON.stringify(report, null, 2) : renderClaims(report));
80
+ return report.clean ? 0 : 1;
81
+ }
82
+ async function runLint(args) {
83
+ let json = false;
84
+ const positional = [];
85
+ for (const arg of args) {
86
+ if (arg === "--json")
87
+ json = true;
88
+ else if (arg.startsWith("-")) {
89
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
90
+ return 2;
91
+ }
92
+ else
93
+ positional.push(arg);
94
+ }
95
+ if (positional.length > 1) {
96
+ console.error(`lint takes at most one path\n\n${USAGE}`);
97
+ return 2;
98
+ }
99
+ let report;
100
+ try {
101
+ report = await lint(positional[0] ?? ".");
102
+ }
103
+ catch (err) {
104
+ console.error(message(err));
105
+ return 2;
106
+ }
107
+ console.log(json ? JSON.stringify(report, null, 2) : renderHuman(report));
108
+ return report.clean ? 0 : 1;
109
+ }
110
+ async function runStrength(args) {
111
+ let json = false;
112
+ let checkOnly = false;
113
+ let mutationReport;
114
+ let coverageSummary;
115
+ const positional = [];
116
+ for (let i = 0; i < args.length; i++) {
117
+ const arg = args[i];
118
+ if (arg === "--json")
119
+ json = true;
120
+ else if (arg === "--check")
121
+ checkOnly = true;
122
+ else if (arg === "--mutation" || arg === "--coverage") {
123
+ const value = args[++i];
124
+ if (value === undefined) {
125
+ console.error(`${arg} needs a file path\n\n${USAGE}`);
126
+ return 2;
127
+ }
128
+ if (arg === "--mutation")
129
+ mutationReport = value;
130
+ else
131
+ coverageSummary = value;
132
+ }
133
+ else if (arg.startsWith("-")) {
134
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
135
+ return 2;
136
+ }
137
+ else
138
+ positional.push(arg);
139
+ }
140
+ if (positional.length > 1) {
141
+ console.error(`strength takes at most one path\n\n${USAGE}`);
142
+ return 2;
143
+ }
144
+ const options = {
145
+ ...(mutationReport !== undefined && { mutationReport }),
146
+ ...(coverageSummary !== undefined && { coverageSummary }),
147
+ };
148
+ if (checkOnly) {
149
+ let checkReport;
150
+ try {
151
+ checkReport = await check(positional[0] ?? ".", options);
152
+ }
153
+ catch (err) {
154
+ console.error(message(err));
155
+ return 2;
156
+ }
157
+ console.log(json ? JSON.stringify(checkReport, null, 2) : renderCheck(checkReport));
158
+ return checkReport.mutation.status === "fresh" && checkReport.coverage.status === "fresh"
159
+ ? 0
160
+ : 1;
161
+ }
162
+ let report;
163
+ try {
164
+ report = await strength(positional[0] ?? ".", options);
165
+ }
166
+ catch (err) {
167
+ console.error(message(err));
168
+ return 2;
169
+ }
170
+ const color = process.stdout.isTTY && process.env.NO_COLOR === undefined;
171
+ console.log(json ? JSON.stringify(report, null, 2) : renderStrength(report, color));
172
+ return 0;
173
+ }
174
+ async function runInit(args) {
175
+ let json = false;
176
+ let skipInstall = false;
177
+ const mutate = [];
178
+ const positional = [];
179
+ for (let i = 0; i < args.length; i++) {
180
+ const arg = args[i];
181
+ if (arg === "--json")
182
+ json = true;
183
+ else if (arg === "--skip-install")
184
+ skipInstall = true;
185
+ else if (arg === "--mutate") {
186
+ const value = args[++i];
187
+ if (value === undefined) {
188
+ console.error(`--mutate needs a glob\n\n${USAGE}`);
189
+ return 2;
190
+ }
191
+ mutate.push(value);
192
+ }
193
+ else if (arg.startsWith("-")) {
194
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
195
+ return 2;
196
+ }
197
+ else
198
+ positional.push(arg);
199
+ }
200
+ if (positional.length > 1) {
201
+ console.error(`strength init takes at most one path\n\n${USAGE}`);
202
+ return 2;
203
+ }
204
+ let report;
205
+ try {
206
+ report = await init(positional[0] ?? ".", { mutate, skipInstall });
207
+ }
208
+ catch (err) {
209
+ console.error(message(err));
210
+ return 2;
211
+ }
212
+ console.log(json ? JSON.stringify(report, null, 2) : renderInit(report));
213
+ return 0;
214
+ }
215
+ function message(err) {
216
+ return err instanceof Error ? err.message : String(err);
217
+ }
218
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,48 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ export function parseCoverageSummary(json, root, source) {
3
+ if (!isRecord(json)) {
4
+ throw new Error(`${source} is not an Istanbul json-summary report: expected a JSON object`);
5
+ }
6
+ const files = new Map();
7
+ let total;
8
+ for (const [key, entry] of Object.entries(json)) {
9
+ const lines = isRecord(entry) ? readLines(entry.lines) : undefined;
10
+ if (!lines)
11
+ continue;
12
+ if (key === "total")
13
+ total = lines;
14
+ else
15
+ files.set(toRootRelative(key, root), lines);
16
+ }
17
+ return { files, total };
18
+ }
19
+ /** Sums the files under `dir`; undefined when the summary knows nothing about it. */
20
+ export function coverageUnder(summary, dir) {
21
+ const prefix = dir === "" ? "" : `${dir}/`;
22
+ let covered = 0;
23
+ let total = 0;
24
+ let matched = false;
25
+ for (const [file, lines] of summary.files) {
26
+ if (!file.startsWith(prefix))
27
+ continue;
28
+ matched = true;
29
+ covered += lines.covered;
30
+ total += lines.total;
31
+ }
32
+ return matched ? { covered, total } : undefined;
33
+ }
34
+ function readLines(value) {
35
+ if (!isRecord(value))
36
+ return undefined;
37
+ const { covered, total } = value;
38
+ if (typeof covered !== "number" || typeof total !== "number")
39
+ return undefined;
40
+ return { covered, total };
41
+ }
42
+ function toRootRelative(key, root) {
43
+ const rel = isAbsolute(key) ? relative(root, key) : relative(root, resolve(root, key));
44
+ return rel.split(/[\\/]/).join("/");
45
+ }
46
+ function isRecord(value) {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
@@ -0,0 +1,43 @@
1
+ const TS_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
2
+ const TS_TEST_TITLE = /\b(?:describe|it|test)(?:\.[\w.]+)*\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g;
3
+ const TS_VITEST = {
4
+ name: "ts-vitest",
5
+ isTestFile: (file) => TS_TEST_FILE.test(file),
6
+ readTestNames: (source) => [...source.matchAll(TS_TEST_TITLE)].map((m) => ({ name: m[2], spelling: "bracketed" })),
7
+ };
8
+ /** `LadderTests.swift` — or any `.swift` file under a `Tests` directory. */
9
+ const SWIFT_TEST_FILE = /Tests?\.swift$/;
10
+ /** Swift Testing display names — `@Test("…")`, `@Suite("…")`: the string is the name. */
11
+ const SWIFT_DISPLAY_NAME = /@(?:Test|Suite)\s*\(\s*"((?:\\.|[^"\\])*)"/g;
12
+ /** XCTest methods: the name is the identifier, so the id takes its identifier-safe spelling. */
13
+ const XCTEST_METHOD = /\bfunc\s+(test[A-Za-z0-9_]*)\s*\(/g;
14
+ /** A `@Test`/`@Suite` declaration carrying no display name — its identifier is the name. */
15
+ const SWIFT_ANNOTATED_DECL = /@(?:Test|Suite)\b(?!\s*\(\s*")(?:\s*\([^()]*\))?(?:\s+(?:@\w+|open|public|internal|fileprivate|private|final|static))*\s+(?:actor|class|enum|func|struct)\s+([A-Za-z_]\w*)/g;
16
+ const SWIFT = {
17
+ name: "swift",
18
+ isTestFile: (file) => SWIFT_TEST_FILE.test(file) || (file.endsWith(".swift") && file.split("/").includes("Tests")),
19
+ readTestNames(source) {
20
+ const found = [...source.matchAll(SWIFT_DISPLAY_NAME)].map((m) => ({ at: m.index, name: m[1], spelling: "bracketed" }));
21
+ // The two identifier scans overlap on an annotated `func test…`, and an identifier
22
+ // names exactly one declaration, so the same name is never read twice.
23
+ const seen = new Set();
24
+ for (const m of [...source.matchAll(XCTEST_METHOD), ...source.matchAll(SWIFT_ANNOTATED_DECL)]) {
25
+ if (seen.has(m[1]))
26
+ continue;
27
+ seen.add(m[1]);
28
+ found.push({ at: m.index, name: m[1], spelling: "identifier" });
29
+ }
30
+ return found.sort((a, b) => a.at - b.at).map(({ name, spelling }) => ({ name, spelling }));
31
+ },
32
+ };
33
+ const DIALECTS = new Map([TS_VITEST, SWIFT].map((dialect) => [dialect.name, dialect]));
34
+ export const DEFAULT_DIALECT = TS_VITEST.name;
35
+ export const DIALECT_NAMES = [...DIALECTS.keys()];
36
+ /** An unsupported stack is unsupported, and says so (ADR-0038). */
37
+ export function resolveDialect(name) {
38
+ const dialect = DIALECTS.get(name);
39
+ if (!dialect) {
40
+ throw new Error(`unknown test dialect: ${name} — known dialects: ${DIALECT_NAMES.join(", ")}`);
41
+ }
42
+ return dialect;
43
+ }