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 +21 -0
- package/README.md +178 -0
- package/dist/check.js +74 -0
- package/dist/claims.js +80 -0
- package/dist/cli.js +218 -0
- package/dist/coverage.js +48 -0
- package/dist/dialects.js +43 -0
- package/dist/discover.js +29 -0
- package/dist/init.js +164 -0
- package/dist/lint.js +20 -0
- package/dist/mutation.js +103 -0
- package/dist/render.js +209 -0
- package/dist/rules/src/index.js +10 -0
- package/dist/rules/src/quality.js +259 -0
- package/dist/rules/src/structural.js +119 -0
- package/dist/spec.js +88 -0
- package/dist/strength.js +187 -0
- package/dist/violation.js +13 -0
- package/package.json +50 -0
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));
|
package/dist/coverage.js
ADDED
|
@@ -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
|
+
}
|
package/dist/dialects.js
ADDED
|
@@ -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
|
+
}
|