speccle 0.15.0 → 0.16.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/README.md +41 -7
- package/dist/changeset.js +4 -9
- package/dist/claims.js +24 -12
- package/dist/cli.js +15 -5
- package/dist/doctor.js +22 -5
- package/dist/git.js +23 -0
- package/dist/init.js +12 -0
- package/dist/render.js +52 -6
- package/dist/risk.js +28 -13
- package/dist/update.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The deterministic tooling the skills invoke — one bin:
|
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
speccle init # record repo facts + vendor the skills into .claude/skills/
|
|
7
|
-
speccle doctor # report staleness across the CLI, skills, and strength stack
|
|
7
|
+
speccle doctor # report staleness across the CLI, skills, CI driver, and strength stack
|
|
8
8
|
speccle update # refresh the vendored skills; print the CLI + stack fix commands
|
|
9
9
|
speccle lint # enforce the convention over a repo's specs
|
|
10
10
|
speccle claims # join criteria to the test names that claim them
|
|
@@ -20,9 +20,10 @@ speccle strength init # provision the strength stack into a target
|
|
|
20
20
|
|
|
21
21
|
- `init` / `doctor` / `update` — the setup and staleness surface: `init` records the
|
|
22
22
|
repo's test facts in `.speccle/config.json` and vendors the skills into
|
|
23
|
-
`.claude/skills/`; `doctor` reports whether those skills
|
|
24
|
-
match this CLI; `update` refreshes the skills
|
|
25
|
-
commands. See
|
|
23
|
+
`.claude/skills/`; `doctor` reports whether those skills, the CI driver's pinned
|
|
24
|
+
version, and the strength stack still match this CLI; `update` refreshes the skills
|
|
25
|
+
forward and prints the CLI + stack fix commands. See
|
|
26
|
+
[Install](https://github.com/matthewalton/speccle/blob/main/README.md#install)
|
|
26
27
|
and [Updating](https://github.com/matthewalton/speccle/blob/main/README.md#updating).
|
|
27
28
|
- `lint` — enforce the [convention](https://github.com/matthewalton/speccle/blob/main/docs/convention.md) over a repo's specs.
|
|
28
29
|
- `claims` — join every criterion to the test names carrying its id, statically. No
|
|
@@ -89,6 +90,21 @@ test no string name, the criterion id takes its identifier-safe spelling:
|
|
|
89
90
|
`func test_CHECKOUT_1_taxRounds()` claims `CHECKOUT-1`. Reports always render the
|
|
90
91
|
bracketed form.
|
|
91
92
|
|
|
93
|
+
With no `--dialect`, the dialect comes from `.speccle/config.json`, resolved **per spec
|
|
94
|
+
folder** — so a mixed-language tree is joined in one pass, each slice under its own dialect
|
|
95
|
+
(the config's longest matching `overrides` path wins). `--dialect` forces one dialect across
|
|
96
|
+
every folder instead. The report names every dialect in play, and each feature the one its
|
|
97
|
+
folder joined under:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
ios/ladder/SPEC.md (swift)
|
|
101
|
+
LADDER-1 1 test name A rung raises the climber by one
|
|
102
|
+
web/basket/SPEC.md (ts-vitest)
|
|
103
|
+
BASKET-1 1 test name When an item is added, its line quantity increments by exactly 1
|
|
104
|
+
|
|
105
|
+
swift, ts-vitest — 2 spec files, 2 criteria, 2 claimed, clean
|
|
106
|
+
```
|
|
107
|
+
|
|
92
108
|
Names are read statically, so a name built dynamically shows up as unclaimed — the
|
|
93
109
|
failure mode is a false alarm, never a silent pass. `--json` emits the typed
|
|
94
110
|
`ClaimsReport` (see [`src/claims.ts`](src/claims.ts)). Exit codes: `0` every criterion
|
|
@@ -198,6 +214,11 @@ edits this file, and a malformed one is a hard error rather than a silently-igno
|
|
|
198
214
|
a quietly dropped signal is exactly the invisible reduction of supervision the score exists to
|
|
199
215
|
prevent.
|
|
200
216
|
|
|
217
|
+
Whether a changed file is production source or a test is a per-path question, and
|
|
218
|
+
`spec-silent-change` answers it from `.speccle/config.json`, resolved **at each changed file's
|
|
219
|
+
own path** — so in a mixed tree an `ios/` slice's `PlayerTests.swift` reads as a test even
|
|
220
|
+
where the repo defaults to `ts-vitest`. `--dialect` forces one dialect across every path instead.
|
|
221
|
+
|
|
201
222
|
`--base` moves both the change set and the criterion baseline to the merge base. That pairing
|
|
202
223
|
is load-bearing: on a branch, HEAD already contains the change, so diffing a changed `SPEC.md`
|
|
203
224
|
against HEAD would compare it with itself and no criterion would ever read as retired or
|
|
@@ -375,6 +396,12 @@ that wrote it. Nothing else is vendored: the driver ships in this tarball and th
|
|
|
375
396
|
fetches it from npm, so the code doing the reviewing never comes from the branch under
|
|
376
397
|
review. Re-running moves the pin, which is how a repo updates the driver.
|
|
377
398
|
|
|
399
|
+
`doctor` reports that pin as its `driver` row, so a workflow left behind on an old version
|
|
400
|
+
shows up as stale rather than sitting there unnoticed; a repo that never opted in reads
|
|
401
|
+
`not installed` and is not a failure. `update` moves the pin **only when the workflow is
|
|
402
|
+
already there** — it will not scaffold one, because opting a repo into a driver that spends
|
|
403
|
+
a metered key per run has to be deliberate.
|
|
404
|
+
|
|
378
405
|
```sh
|
|
379
406
|
speccle review run --pr <number> [path] [--repo <owner/name>] [--base <ref>]
|
|
380
407
|
[--force] [--model <id>] [--dry-run] [--json]
|
|
@@ -425,9 +452,16 @@ instead of a hand-assembled config recipe. In one run it:
|
|
|
425
452
|
`features/**/*.ts`; override with `--mutate`, repeatable);
|
|
426
453
|
- writes a `vitest.config.ts` with the istanbul provider and `json-summary` reporter.
|
|
427
454
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
455
|
+
It also reports a `speccle-oracle` devDependency as **superseded** — the name this CLI
|
|
456
|
+
published under before 0.11.0. A repo provisioned back then keeps that package beside the
|
|
457
|
+
current one, exposing a stale binary in `node_modules/.bin/`. Init names the removal
|
|
458
|
+
command and stops there: your `package.json` is yours to change.
|
|
459
|
+
|
|
460
|
+
Both init commands warn (best-effort, via `~/.claude/settings.json`) when the target
|
|
461
|
+
vendors the speccle skills project-level in `.claude/skills/` while a user-level speccle
|
|
462
|
+
plugin is still enabled — two copies of every skill would load. `speccle init` is the run
|
|
463
|
+
that vendors them, so it warns too rather than leaving the discovery to a `strength init`
|
|
464
|
+
a contract-only user may never run.
|
|
431
465
|
|
|
432
466
|
An existing Stryker or vitest/vite config is **kept, never overwritten** — init reports
|
|
433
467
|
it and names the fields it must carry itself. The command is idempotent: re-running
|
package/dist/changeset.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { readFile, stat } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
3
|
+
import { gitStdout } from "./git.js";
|
|
4
4
|
/** The changed files a predicate matches — by path glob, then by content when `contains` is set. */
|
|
5
5
|
export async function matching(predicate, changed, read) {
|
|
6
6
|
const pattern = globToRegExp(predicate.path);
|
|
@@ -38,7 +38,7 @@ export function gitChangeSet(root) {
|
|
|
38
38
|
// --untracked-files=all lists new files individually; the default collapses a wholly
|
|
39
39
|
// untracked directory to its name, hiding the files that must be seen.
|
|
40
40
|
const args = ["status", "--porcelain", "--untracked-files=all"];
|
|
41
|
-
const stdout =
|
|
41
|
+
const stdout = gitStdout(root, args);
|
|
42
42
|
if (stdout === undefined) {
|
|
43
43
|
throw new Error("could not read a change set from git — run this inside a git repository");
|
|
44
44
|
}
|
|
@@ -59,23 +59,18 @@ export function gitChangeSet(root) {
|
|
|
59
59
|
* left it are not attributed to this change; that is the set a pull request shows.
|
|
60
60
|
*/
|
|
61
61
|
export function gitRangeChangeSet(root, base) {
|
|
62
|
-
const mergeBase =
|
|
62
|
+
const mergeBase = gitStdout(root, ["merge-base", base, "HEAD"])?.trim();
|
|
63
63
|
if (mergeBase === undefined || mergeBase === "") {
|
|
64
64
|
// The likeliest cause in CI by far: a shallow checkout that fetched no shared history.
|
|
65
65
|
throw new Error(`no merge base between "${base}" and HEAD — fetch enough history for the two to share a commit`);
|
|
66
66
|
}
|
|
67
67
|
// A rename reports its destination path only, matching the working tree's "old -> new".
|
|
68
|
-
const stdout =
|
|
68
|
+
const stdout = gitStdout(root, ["diff", "--name-only", mergeBase, "HEAD"]);
|
|
69
69
|
if (stdout === undefined)
|
|
70
70
|
throw new Error(`could not diff "${base}" against HEAD`);
|
|
71
71
|
const changed = stdout.split("\n").filter((line) => line !== "");
|
|
72
72
|
return { changed: [...new Set(changed)], baseline: mergeBase };
|
|
73
73
|
}
|
|
74
|
-
/** Git's stdout, or undefined when the command could not run or failed. */
|
|
75
|
-
function git(root, args) {
|
|
76
|
-
const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
|
77
|
-
return result.error === undefined && result.status === 0 ? result.stdout : undefined;
|
|
78
|
-
}
|
|
79
74
|
/** Validates a predicate's shape, naming the offending file — a silently broken predicate is worse than none. */
|
|
80
75
|
export function assertPredicate(predicate, key, file) {
|
|
81
76
|
if (predicate === undefined)
|
package/dist/claims.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { readConfig } from "./config.js";
|
|
3
|
+
import { readConfig, resolveFacts } from "./config.js";
|
|
4
4
|
import { DEFAULT_DIALECT, resolveDialect } from "./dialects.js";
|
|
5
5
|
import { discoverSpecs, discoverTests } from "./discover.js";
|
|
6
6
|
import { compareCriterionIds, parseSpec, readClaimedIds } from "./spec.js";
|
|
@@ -8,10 +8,13 @@ export async function claims(target, options = {}) {
|
|
|
8
8
|
const root = resolve(target);
|
|
9
9
|
if (!(await isDirectory(root)))
|
|
10
10
|
throw new Error(`path not found: ${target}`);
|
|
11
|
-
// An explicit --dialect wins outright; otherwise `.speccle/config.json` is the
|
|
12
|
-
// of truth,
|
|
13
|
-
|
|
14
|
-
const
|
|
11
|
+
// An explicit --dialect wins outright, everywhere; otherwise `.speccle/config.json` is the
|
|
12
|
+
// source of truth, and its per-path overrides let one pass join a mixed-language tree under
|
|
13
|
+
// each folder's own dialect (ADR-0040). The default applies only to a repo with no config.
|
|
14
|
+
const forced = options.dialect === undefined ? undefined : resolveDialect(options.dialect);
|
|
15
|
+
const config = forced === undefined ? await readConfig(root) : undefined;
|
|
16
|
+
const dialectAt = (folder) => forced ??
|
|
17
|
+
resolveDialect(config === undefined ? DEFAULT_DIALECT : resolveFacts(config, folder).dialect);
|
|
15
18
|
const specFiles = await discoverSpecs(root);
|
|
16
19
|
const specs = await Promise.all(specFiles.map(async (file) => parseSpec(await readFile(join(root, file), "utf8"), file)));
|
|
17
20
|
const criteria = new Map();
|
|
@@ -25,18 +28,23 @@ export async function claims(target, options = {}) {
|
|
|
25
28
|
// A slice's tests live in its own folder: only test files under a spec's folder
|
|
26
29
|
// count, so unrelated tooling tests can never claim (or phantom-claim) a criterion.
|
|
27
30
|
const folders = [...new Set(specFiles.map((file) => dirname(file)))];
|
|
28
|
-
const
|
|
29
|
-
|
|
31
|
+
const folderDialects = new Map(folders.map((folder) => [folder, dialectAt(folder)]));
|
|
32
|
+
// Nested spec folders can each discover the same test file, under different dialects. The
|
|
33
|
+
// deepest folder's dialect reads it — the same most-specific-path rule the config resolves
|
|
34
|
+
// an override under, so walking the folders shortest-first lets the deepest one win.
|
|
35
|
+
const fileDialects = new Map();
|
|
36
|
+
for (const folder of [...folders].sort((a, b) => a.length - b.length)) {
|
|
37
|
+
const folderDialect = folderDialects.get(folder);
|
|
30
38
|
const abs = folder === "." ? root : join(root, folder);
|
|
31
|
-
for (const file of await discoverTests(abs,
|
|
32
|
-
|
|
39
|
+
for (const file of await discoverTests(abs, folderDialect)) {
|
|
40
|
+
fileDialects.set(folder === "." ? file : `${folder}/${file}`, folderDialect);
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
|
-
const testFiles = [...
|
|
43
|
+
const testFiles = [...fileDialects.keys()].sort();
|
|
36
44
|
const claimsById = new Map();
|
|
37
45
|
for (const file of testFiles) {
|
|
38
46
|
const source = await readFile(join(root, file), "utf8");
|
|
39
|
-
for (const { name, spelling } of
|
|
47
|
+
for (const { name, spelling } of fileDialects.get(file).readTestNames(source)) {
|
|
40
48
|
for (const id of readClaimedIds(name, spelling)) {
|
|
41
49
|
const entry = claimsById.get(id) ?? [];
|
|
42
50
|
entry.push({ file, name });
|
|
@@ -47,6 +55,7 @@ export async function claims(target, options = {}) {
|
|
|
47
55
|
const features = specs.map((spec) => ({
|
|
48
56
|
key: spec.key?.raw,
|
|
49
57
|
spec: spec.file,
|
|
58
|
+
dialect: folderDialects.get(dirname(spec.file)).name,
|
|
50
59
|
criteria: [...criteria.entries()]
|
|
51
60
|
.filter(([, value]) => value.spec === spec.file)
|
|
52
61
|
.map(([id, value]) => ({
|
|
@@ -64,9 +73,12 @@ export async function claims(target, options = {}) {
|
|
|
64
73
|
.filter(([id]) => !criteria.has(id))
|
|
65
74
|
.map(([id, tests]) => ({ id, tests }))
|
|
66
75
|
.sort((a, b) => compareCriterionIds(a.id, b.id));
|
|
76
|
+
// With no spec at all no folder resolved a dialect, so name the one a pass at the root
|
|
77
|
+
// would have run under rather than reporting none in play.
|
|
78
|
+
const inPlay = folders.length === 0 ? [dialectAt(".")] : [...folderDialects.values()];
|
|
67
79
|
return {
|
|
68
80
|
root,
|
|
69
|
-
|
|
81
|
+
dialects: [...new Set(inPlay.map((entry) => entry.name))].sort(),
|
|
70
82
|
testFiles,
|
|
71
83
|
features,
|
|
72
84
|
unclaimed,
|
package/dist/cli.js
CHANGED
|
@@ -5,11 +5,11 @@ import { claims } from "./claims.js";
|
|
|
5
5
|
import { initConfig } from "./config.js";
|
|
6
6
|
import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
|
|
7
7
|
import { doctor } from "./doctor.js";
|
|
8
|
-
import { init, ownVersion } from "./init.js";
|
|
8
|
+
import { detectDoubleLoad, init, ownVersion } from "./init.js";
|
|
9
9
|
import { materializeLenses } from "./lenses.js";
|
|
10
10
|
import { lint } from "./lint.js";
|
|
11
11
|
import { recallRemedy, recordRemedy, REMEDY_ROUTES } from "./remedy.js";
|
|
12
|
-
import { renderCalibrateRecord, renderCalibrateReport, renderCheck, renderClaims, renderConfigInit, renderDoctor, renderHuman, renderInit, renderLensesInit, renderRemedyRecall, renderRemedyRecord, renderReviewInit, renderReviewRun, renderRisk, renderSkillsInit, renderStrength, renderUpdate, renderVerify, } from "./render.js";
|
|
12
|
+
import { renderCalibrateRecord, renderCalibrateReport, renderCheck, renderClaims, renderConfigInit, renderDoctor, renderDoubleLoad, renderHuman, renderInit, renderLensesInit, renderRemedyRecall, renderRemedyRecord, renderReviewInit, renderReviewRun, renderRisk, renderSkillsInit, renderStrength, renderUpdate, renderVerify, } from "./render.js";
|
|
13
13
|
import { scaffoldReviewWorkflow } from "./reviewinit.js";
|
|
14
14
|
import { reviewRun } from "./reviewrun.js";
|
|
15
15
|
import { risk } from "./risk.js";
|
|
@@ -22,8 +22,10 @@ const USAGE = `Usage: speccle <command> [options]
|
|
|
22
22
|
Commands:
|
|
23
23
|
init [path] [--json] Record repo facts in .speccle/config.json, materialize the
|
|
24
24
|
skills into .claude/skills/ and the lenses into .speccle/lenses/
|
|
25
|
-
doctor [path] [--json] Report staleness across the CLI, skills, lenses,
|
|
26
|
-
|
|
25
|
+
doctor [path] [--json] Report staleness across the CLI, skills, lenses, the CI driver's
|
|
26
|
+
pin, and the strength stack
|
|
27
|
+
update [path] [--json] Refresh the skills and lenses as a diff, and an already-installed
|
|
28
|
+
CI driver's pin; report stack and binary fixes
|
|
27
29
|
lint [path] [--json] Lint every SPEC.md under path (default: current directory)
|
|
28
30
|
claims [path] [--json] Join criteria to the test names that claim them — no reports needed
|
|
29
31
|
verify [path] [--json] Run .speccle/checks/ against the change set: cross-file invariants
|
|
@@ -640,19 +642,23 @@ async function runInit(args) {
|
|
|
640
642
|
let config;
|
|
641
643
|
let skills;
|
|
642
644
|
let lenses;
|
|
645
|
+
let doubleLoad;
|
|
643
646
|
try {
|
|
644
647
|
// Materialize first, then stamp the version onto the config — so the recorded anchors
|
|
645
648
|
// only ever name the skills and lenses that actually landed on disk.
|
|
646
649
|
skills = await materializeSkills(root);
|
|
647
650
|
lenses = await materializeLenses(root);
|
|
648
651
|
config = await initConfig(root, await ownVersion());
|
|
652
|
+
// Asked after materializing: this run is what makes the repo a project-level vendor, so
|
|
653
|
+
// the double-load it may have just created is exactly what the human needs told (#183).
|
|
654
|
+
doubleLoad = await detectDoubleLoad(root);
|
|
649
655
|
}
|
|
650
656
|
catch (err) {
|
|
651
657
|
console.error(message(err));
|
|
652
658
|
return 2;
|
|
653
659
|
}
|
|
654
660
|
if (json) {
|
|
655
|
-
console.log(JSON.stringify({ config, skills, lenses }, null, 2));
|
|
661
|
+
console.log(JSON.stringify({ config, skills, lenses, doubleLoad }, null, 2));
|
|
656
662
|
}
|
|
657
663
|
else {
|
|
658
664
|
console.log(renderConfigInit(config));
|
|
@@ -660,6 +666,10 @@ async function runInit(args) {
|
|
|
660
666
|
console.log(renderSkillsInit(skills));
|
|
661
667
|
console.log("");
|
|
662
668
|
console.log(renderLensesInit(lenses));
|
|
669
|
+
if (doubleLoad) {
|
|
670
|
+
console.log("");
|
|
671
|
+
console.log(renderDoubleLoad());
|
|
672
|
+
}
|
|
663
673
|
}
|
|
664
674
|
return 0;
|
|
665
675
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -3,12 +3,14 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { readConfig } from "./config.js";
|
|
4
4
|
import { ownVersion, STRENGTH_DEPS, STRYKER_CONFIG_NAMES } from "./init.js";
|
|
5
5
|
import { LENSES_DIR } from "./lenses.js";
|
|
6
|
+
import { pinnedVersion, WORKFLOW_FILE } from "./reviewinit.js";
|
|
6
7
|
import { SKILLS_DIR } from "./skills.js";
|
|
7
8
|
/**
|
|
8
|
-
* Reports the truth about
|
|
9
|
-
* committed skills, and the strength stack — and never
|
|
10
|
-
* Offline and deterministic: it compares the repo against
|
|
11
|
-
* the npm registry — "is there a newer release" is `update`'s
|
|
9
|
+
* Reports the truth about everything that drifts in a Speccle consumer — the CLI, the
|
|
10
|
+
* committed skills and lenses, the CI driver's pin, and the strength stack — and never
|
|
11
|
+
* mutates a byte (#182, ADR-0046). Offline and deterministic: it compares the repo against
|
|
12
|
+
* the installed CLI, not against the npm registry — "is there a newer release" is `update`'s
|
|
13
|
+
* network job, not this one's.
|
|
12
14
|
*/
|
|
13
15
|
export async function doctor(target) {
|
|
14
16
|
const root = resolve(target);
|
|
@@ -20,6 +22,9 @@ export async function doctor(target) {
|
|
|
20
22
|
const skillsStatus = derivePayloadStatus(await hasSkills(root), skillsRecorded, cli);
|
|
21
23
|
const lensesRecorded = config?.lensesVersion ?? null;
|
|
22
24
|
const lensesStatus = derivePayloadStatus(await hasLenses(root), lensesRecorded, cli);
|
|
25
|
+
const workflow = await readMaybe(join(root, WORKFLOW_FILE));
|
|
26
|
+
const driverPin = workflow === undefined ? null : (pinnedVersion(workflow) ?? null);
|
|
27
|
+
const driverStatus = derivePayloadStatus(workflow !== undefined, driverPin, cli);
|
|
23
28
|
const provisioned = await anyPresent(root, STRYKER_CONFIG_NAMES);
|
|
24
29
|
const declared = await declaredDeps(root);
|
|
25
30
|
const deps = STRENGTH_DEPS.map((spec) => checkDep(spec, declared));
|
|
@@ -29,12 +34,16 @@ export async function doctor(target) {
|
|
|
29
34
|
? "drift"
|
|
30
35
|
: "current";
|
|
31
36
|
const current = (status) => status === "current" || status === "absent";
|
|
32
|
-
const ok = current(skillsStatus) &&
|
|
37
|
+
const ok = current(skillsStatus) &&
|
|
38
|
+
current(lensesStatus) &&
|
|
39
|
+
current(driverStatus) &&
|
|
40
|
+
stackStatus !== "drift";
|
|
33
41
|
return {
|
|
34
42
|
root,
|
|
35
43
|
cli,
|
|
36
44
|
skills: { recorded: skillsRecorded, bundled: cli, status: skillsStatus },
|
|
37
45
|
lenses: { recorded: lensesRecorded, bundled: cli, status: lensesStatus },
|
|
46
|
+
driver: { recorded: driverPin, bundled: cli, status: driverStatus },
|
|
38
47
|
stack: { provisioned, deps, status: stackStatus },
|
|
39
48
|
ok,
|
|
40
49
|
};
|
|
@@ -96,6 +105,14 @@ async function hasLenses(root) {
|
|
|
96
105
|
return false;
|
|
97
106
|
}
|
|
98
107
|
}
|
|
108
|
+
async function readMaybe(path) {
|
|
109
|
+
try {
|
|
110
|
+
return await readFile(path, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
99
116
|
async function anyPresent(root, names) {
|
|
100
117
|
for (const name of names) {
|
|
101
118
|
if (await exists(join(root, name)))
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Spawning by name makes every call walk PATH, and the misses are what cost — measured at ~8ms
|
|
6
|
+
* each on macOS, against ~2ms for git itself. Under `npx`/`pnpm` the caller's PATH puts a dozen
|
|
7
|
+
* or more entries ahead of git's, so `risk`, which reads a baseline once per changed spec file,
|
|
8
|
+
* paid that walk per file. Resolved once here instead: the binary is fixed at module load rather
|
|
9
|
+
* than per call, which suits a short-lived CLI but means a PATH edit mid-process is not seen.
|
|
10
|
+
*/
|
|
11
|
+
const GIT = (process.env.PATH ?? "")
|
|
12
|
+
.split(delimiter)
|
|
13
|
+
.map((dir) => join(dir, "git"))
|
|
14
|
+
.find((candidate) => existsSync(candidate)) ?? "git";
|
|
15
|
+
/** Run one git command in `root`, with the binary resolved once. */
|
|
16
|
+
export function git(root, args) {
|
|
17
|
+
return spawnSync(GIT, args, { cwd: root, encoding: "utf8" });
|
|
18
|
+
}
|
|
19
|
+
/** Git's stdout, or undefined when the command could not run or failed. */
|
|
20
|
+
export function gitStdout(root, args) {
|
|
21
|
+
const result = git(root, args);
|
|
22
|
+
return result.error === undefined && result.status === 0 ? result.stdout : undefined;
|
|
23
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -10,6 +10,10 @@ export const STRENGTH_DEPS = [
|
|
|
10
10
|
"@stryker-mutator/core@^9",
|
|
11
11
|
"@stryker-mutator/vitest-runner@^9",
|
|
12
12
|
];
|
|
13
|
+
// Names this CLI has published under before (ADR-0045). A repo provisioned under an old name
|
|
14
|
+
// keeps that package beside the current one, and `node_modules/.bin/` still exposes its stale
|
|
15
|
+
// binary — dead weight the ladder never picks, but weight the repo should know it is carrying.
|
|
16
|
+
export const SUPERSEDED_DEPS = ["speccle-oracle"];
|
|
13
17
|
const LOCKFILES = [
|
|
14
18
|
["pnpm-lock.yaml", "pnpm"],
|
|
15
19
|
["package-lock.json", "npm"],
|
|
@@ -67,6 +71,8 @@ export async function init(root, options = {}) {
|
|
|
67
71
|
const wantedDeps = [`speccle@^${await ownVersion()}`, ...STRENGTH_DEPS];
|
|
68
72
|
const missingDeps = wantedDeps.filter((spec) => !declared.has(withoutVersion(spec)));
|
|
69
73
|
const installCommand = missingDeps.length > 0 ? installCommandFor(packageManager, missingDeps) : null;
|
|
74
|
+
const supersededDeps = SUPERSEDED_DEPS.filter((name) => declared.has(name));
|
|
75
|
+
const removeCommand = supersededDeps.length > 0 ? removeCommandFor(packageManager, supersededDeps) : null;
|
|
70
76
|
const files = [
|
|
71
77
|
await provision(root, STRYKER_CONFIG_NAMES, "stryker.config.json", () => JSON.stringify(strykerConfig(packageManager, mutate), null, 2) + "\n"),
|
|
72
78
|
await provision(root, VITEST_CONFIG_NAMES, "vitest.config.ts", () => vitestConfig(mutate)),
|
|
@@ -83,6 +89,8 @@ export async function init(root, options = {}) {
|
|
|
83
89
|
missingDeps,
|
|
84
90
|
installCommand,
|
|
85
91
|
installRan,
|
|
92
|
+
supersededDeps,
|
|
93
|
+
removeCommand,
|
|
86
94
|
mutate,
|
|
87
95
|
files,
|
|
88
96
|
doubleLoad,
|
|
@@ -143,6 +151,10 @@ export function installCommandFor(manager, deps) {
|
|
|
143
151
|
const subcommand = manager === "npm" ? "install -D" : manager === "bun" ? "add -d" : "add -D";
|
|
144
152
|
return `${manager} ${subcommand} ${deps.join(" ")}`;
|
|
145
153
|
}
|
|
154
|
+
export function removeCommandFor(manager, deps) {
|
|
155
|
+
const subcommand = manager === "npm" ? "uninstall" : "remove";
|
|
156
|
+
return `${manager} ${subcommand} ${deps.join(" ")}`;
|
|
157
|
+
}
|
|
146
158
|
function runInstall(root, command) {
|
|
147
159
|
const [manager, ...args] = command.split(" ");
|
|
148
160
|
const result = spawnSync(manager, args, { cwd: root, encoding: "utf8" });
|
package/dist/render.js
CHANGED
|
@@ -87,6 +87,7 @@ export function renderDoctor(report) {
|
|
|
87
87
|
"",
|
|
88
88
|
`skills ${describePayload(report.skills, "materialized")}`,
|
|
89
89
|
`lenses ${describePayload(report.lenses, "vendored")}`,
|
|
90
|
+
`driver ${describeDriver(report.driver)}`,
|
|
90
91
|
`stack ${describeStack(report.stack)}`,
|
|
91
92
|
];
|
|
92
93
|
if (report.stack.status === "drift") {
|
|
@@ -100,6 +101,7 @@ export function renderDoctor(report) {
|
|
|
100
101
|
lines.push("");
|
|
101
102
|
const allAbsent = report.skills.status === "absent" &&
|
|
102
103
|
report.lenses.status === "absent" &&
|
|
104
|
+
report.driver.status === "absent" &&
|
|
103
105
|
report.stack.status === "absent";
|
|
104
106
|
if (allAbsent) {
|
|
105
107
|
lines.push("Speccle is not set up here — run `speccle init`");
|
|
@@ -126,6 +128,25 @@ function describePayload(payload, verb) {
|
|
|
126
128
|
return `not ${verb} — run \`speccle init\``;
|
|
127
129
|
}
|
|
128
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The driver's own arm, because its two ends differ from a vendored payload's: absent means
|
|
133
|
+
* the repo never opted into a metered CI driver — a choice, not a gap to fix — and every
|
|
134
|
+
* remedy is `review init`, which is also the only thing that moves the pin.
|
|
135
|
+
*/
|
|
136
|
+
function describeDriver(driver) {
|
|
137
|
+
switch (driver.status) {
|
|
138
|
+
case "current":
|
|
139
|
+
return `current (${driver.bundled})`;
|
|
140
|
+
case "stale":
|
|
141
|
+
return `stale — the workflow pins ${driver.recorded}, this CLI is ${driver.bundled}; re-run \`speccle review init\``;
|
|
142
|
+
case "ahead":
|
|
143
|
+
return `ahead — the workflow pins ${driver.recorded}, newer than this CLI (${driver.bundled})`;
|
|
144
|
+
case "unstamped":
|
|
145
|
+
return "present but unpinned — re-run `speccle review init` to pin a version";
|
|
146
|
+
case "absent":
|
|
147
|
+
return "not installed — opt in with `speccle review init`";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
129
150
|
function describeStack(stack) {
|
|
130
151
|
if (stack.status === "absent")
|
|
131
152
|
return "not provisioned — run `speccle strength init`";
|
|
@@ -155,6 +176,15 @@ export function renderUpdate(report) {
|
|
|
155
176
|
else {
|
|
156
177
|
lines.push(`lenses ${report.lenses.from} → ${report.lenses.to} — review & commit the diff`);
|
|
157
178
|
}
|
|
179
|
+
if (report.driver.to === null) {
|
|
180
|
+
lines.push("driver not installed — opt in with `speccle review init`");
|
|
181
|
+
}
|
|
182
|
+
else if (report.driver.from === report.driver.to) {
|
|
183
|
+
lines.push(`driver already pinned to ${report.driver.to} — rewritten in place`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
lines.push(`driver ${report.driver.from ?? "unpinned"} → ${report.driver.to} — review & commit the diff`);
|
|
187
|
+
}
|
|
158
188
|
if (report.stack.status === "absent") {
|
|
159
189
|
lines.push("stack not provisioned — run `speccle strength init`");
|
|
160
190
|
}
|
|
@@ -334,17 +364,21 @@ export function renderClaims(report) {
|
|
|
334
364
|
if (report.features.length === 0)
|
|
335
365
|
return "No SPEC.md files found.";
|
|
336
366
|
const idWidth = Math.max(...report.features.flatMap((f) => f.criteria.map((c) => c.id.length)));
|
|
367
|
+
// Only a mixed pass needs to say which dialect read which slice; naming the one dialect
|
|
368
|
+
// above every spec in the common case is noise the footer already carries.
|
|
369
|
+
const mixed = report.dialects.length > 1;
|
|
337
370
|
const lines = [];
|
|
338
371
|
for (const feature of report.features) {
|
|
339
|
-
lines.push(feature.spec);
|
|
372
|
+
lines.push(mixed ? `${feature.spec} (${feature.dialect})` : feature.spec);
|
|
340
373
|
for (const c of feature.criteria) {
|
|
341
374
|
const status = c.claimed ? plural(c.tests.length, "test name") : "unclaimed";
|
|
342
375
|
lines.push(` ${c.id.padEnd(idWidth)} ${status.padEnd(13)} ${c.statement}`);
|
|
343
376
|
}
|
|
344
377
|
lines.push("");
|
|
345
378
|
}
|
|
379
|
+
const dialects = report.dialects.join(", ");
|
|
346
380
|
if (report.testFiles.length === 0) {
|
|
347
|
-
lines.push(`no test files matched the ${
|
|
381
|
+
lines.push(`no test files matched the ${dialects} dialect${mixed ? "s" : ""}`);
|
|
348
382
|
lines.push("");
|
|
349
383
|
}
|
|
350
384
|
if (report.unclaimed.length > 0) {
|
|
@@ -364,7 +398,7 @@ export function renderClaims(report) {
|
|
|
364
398
|
const claimed = report.features.reduce((n, f) => n + f.criteria.filter((c) => c.claimed).length, 0);
|
|
365
399
|
const criteria = `${total} ${total === 1 ? "criterion" : "criteria"}`;
|
|
366
400
|
const specs = plural(report.features.length, "spec file");
|
|
367
|
-
const counts = `${
|
|
401
|
+
const counts = `${dialects} — ${specs}, ${criteria}, ${claimed} claimed`;
|
|
368
402
|
lines.push(report.clean ? `${counts}, clean` : counts);
|
|
369
403
|
return lines.join("\n");
|
|
370
404
|
}
|
|
@@ -388,14 +422,26 @@ export function renderInit(report) {
|
|
|
388
422
|
lines.push(' stryker config: coverageAnalysis "perTest", the json reporter');
|
|
389
423
|
lines.push(" vitest config: istanbul provider, json-summary reporter");
|
|
390
424
|
}
|
|
425
|
+
if (report.supersededDeps.length > 0) {
|
|
426
|
+
lines.push("");
|
|
427
|
+
lines.push(`superseded devDependency: ${report.supersededDeps.join(", ")}`);
|
|
428
|
+
lines.push("this CLI publishes as speccle now — the old package is a stale binary beside");
|
|
429
|
+
lines.push(`the current one. Remove it yourself: ${report.removeCommand}`);
|
|
430
|
+
}
|
|
391
431
|
if (report.doubleLoad) {
|
|
392
432
|
lines.push("");
|
|
393
|
-
lines.push(
|
|
394
|
-
lines.push("speccle plugin is enabled user-level — two copies of every skill will");
|
|
395
|
-
lines.push("load. Disable one: /plugin (user-level) or remove .claude/skills/ here.");
|
|
433
|
+
lines.push(renderDoubleLoad());
|
|
396
434
|
}
|
|
397
435
|
return lines.join("\n");
|
|
398
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* One wording for both entry points. `speccle init` vendors the skills and `speccle strength
|
|
439
|
+
* init` provisions the stack; either run is a fair place to discover the double-load, so
|
|
440
|
+
* neither stays quiet about it (#183).
|
|
441
|
+
*/
|
|
442
|
+
export function renderDoubleLoad() {
|
|
443
|
+
return join("warning: this repo vendors the speccle skills project-level AND the", "speccle plugin is enabled user-level — two copies of every skill will", "load. Disable one: /plugin (user-level) or remove .claude/skills/ here.");
|
|
444
|
+
}
|
|
399
445
|
export function renderHuman(report) {
|
|
400
446
|
if (report.files.length === 0)
|
|
401
447
|
return "No SPEC.md files found.";
|
package/dist/risk.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { dirname, resolve } from "node:path";
|
|
4
3
|
import { assertPredicate, contentReader, gitChangeSet, gitRangeChangeSet, isDirectory, matching, messageOf, } from "./changeset.js";
|
|
5
4
|
import { claims } from "./claims.js";
|
|
6
|
-
import { readConfig } from "./config.js";
|
|
5
|
+
import { readConfig, resolveFacts } from "./config.js";
|
|
7
6
|
import { DEFAULT_DIALECT, resolveDialect } from "./dialects.js";
|
|
8
7
|
import { discoverSpecs } from "./discover.js";
|
|
8
|
+
import { gitStdout } from "./git.js";
|
|
9
9
|
import { parseSpec } from "./spec.js";
|
|
10
10
|
/** Where a repo declares its risk policy (ADR-0041) — judgement, so a single hand-edited file. */
|
|
11
11
|
export const RISK_POLICY_FILE = ".speccle/risk.json";
|
|
@@ -30,8 +30,9 @@ export async function risk(target, options = {}) {
|
|
|
30
30
|
// reads from, and the two must agree or the diff is measured against the wrong commit.
|
|
31
31
|
const range = options.base === undefined ? undefined : gitRangeChangeSet(root, options.base);
|
|
32
32
|
const changed = (options.changed ?? range?.changed ?? gitChangeSet(root)).slice().sort();
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Resolved here rather than where it is used, so a bogus `--dialect` fails loudly even when
|
|
34
|
+
// every signal that would have read it is muted.
|
|
35
|
+
const forced = options.dialect === undefined ? undefined : resolveDialect(options.dialect);
|
|
35
36
|
const baselineRef = range?.baseline ?? "HEAD";
|
|
36
37
|
const baseline = options.baseline ?? ((file) => gitBaseline(root, file, baselineRef));
|
|
37
38
|
const read = contentReader(root);
|
|
@@ -45,7 +46,7 @@ export async function risk(target, options = {}) {
|
|
|
45
46
|
// Baseline signals — the spec-aware facts only Speccle can see. A muted (0-weight) signal is
|
|
46
47
|
// not computed at all, keeping the git and claims work off the path when a repo has opted out.
|
|
47
48
|
if (weightOf("spec-silent-change") > 0) {
|
|
48
|
-
fireBaseline("spec-silent-change", "production source changed in a governed slice whose SPEC.md did not", await specSilentChanges(root, changed,
|
|
49
|
+
fireBaseline("spec-silent-change", "production source changed in a governed slice whose SPEC.md did not", await specSilentChanges(root, changed, forced));
|
|
49
50
|
}
|
|
50
51
|
if (weightOf("criterion-retired") > 0 || weightOf("criterion-reworded") > 0) {
|
|
51
52
|
const { retired, reworded } = await criterionDiffs(changed, read, baseline);
|
|
@@ -53,7 +54,7 @@ export async function risk(target, options = {}) {
|
|
|
53
54
|
fireBaseline("criterion-reworded", "a criterion's statement was reworded", reworded);
|
|
54
55
|
}
|
|
55
56
|
if (weightOf("unclaimed-change") > 0) {
|
|
56
|
-
fireBaseline("unclaimed-change", "changed code lives in a slice with a criterion no test claims", await unclaimedChanges(root, changed, dialect
|
|
57
|
+
fireBaseline("unclaimed-change", "changed code lives in a slice with a criterion no test claims", await unclaimedChanges(root, changed, options.dialect));
|
|
57
58
|
}
|
|
58
59
|
// Policy signals — repo-defined predicates over the change set.
|
|
59
60
|
for (const signal of policy.signals ?? []) {
|
|
@@ -82,9 +83,20 @@ export async function risk(target, options = {}) {
|
|
|
82
83
|
humanRequired: score >= threshold,
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
|
-
/**
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Governed-slice production source that changed while the slice's SPEC.md stayed silent.
|
|
88
|
+
*
|
|
89
|
+
* Whether a changed file is a test is a per-path question, so with no forced dialect each file
|
|
90
|
+
* is judged under the one the config resolves at its own path (ADR-0040) — a swift slice's
|
|
91
|
+
* `PlayerTests.swift` is a test even in a repo that defaults to ts-vitest. Resolved at the file
|
|
92
|
+
* rather than its spec folder: that also honours an override deeper than the folder, and keeps
|
|
93
|
+
* an outer slice from misreading a nested slice's tests as production source.
|
|
94
|
+
*/
|
|
95
|
+
async function specSilentChanges(root, changed, forced) {
|
|
87
96
|
const specs = await discoverSpecs(root);
|
|
97
|
+
const config = forced === undefined ? await readConfig(root) : undefined;
|
|
98
|
+
const dialectAt = (file) => forced ??
|
|
99
|
+
resolveDialect(config === undefined ? DEFAULT_DIALECT : resolveFacts(config, file).dialect);
|
|
88
100
|
const silent = [];
|
|
89
101
|
for (const spec of specs) {
|
|
90
102
|
const folder = dirname(spec);
|
|
@@ -93,7 +105,7 @@ async function specSilentChanges(root, changed, dialect) {
|
|
|
93
105
|
for (const file of changed) {
|
|
94
106
|
if (!underFolder(file, folder))
|
|
95
107
|
continue;
|
|
96
|
-
if (isContractFile(file, folder) ||
|
|
108
|
+
if (isContractFile(file, folder) || dialectAt(file).isTestFile(file))
|
|
97
109
|
continue;
|
|
98
110
|
silent.push(file);
|
|
99
111
|
}
|
|
@@ -119,9 +131,13 @@ async function criterionDiffs(changed, read, baseline) {
|
|
|
119
131
|
}
|
|
120
132
|
return { retired, reworded };
|
|
121
133
|
}
|
|
122
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Unclaimed criterion ids in the governed slices this change touched. Only an explicit
|
|
136
|
+
* `--dialect` is passed down: without one, `claims` resolves each slice's own dialect from the
|
|
137
|
+
* config, so a mixed-language tree is not joined under whichever dialect the repo defaults to.
|
|
138
|
+
*/
|
|
123
139
|
async function unclaimedChanges(root, changed, dialect) {
|
|
124
|
-
const report = await claims(root, { dialect });
|
|
140
|
+
const report = await claims(root, { ...(dialect !== undefined && { dialect }) });
|
|
125
141
|
const unclaimed = [];
|
|
126
142
|
for (const feature of report.features) {
|
|
127
143
|
const folder = dirname(feature.spec);
|
|
@@ -205,8 +221,7 @@ function assertPolicy(policy) {
|
|
|
205
221
|
}
|
|
206
222
|
/** Content of a path at `ref`, or undefined when git has no baseline for it (new or no commits). */
|
|
207
223
|
function gitBaseline(root, file, ref) {
|
|
208
|
-
|
|
209
|
-
return result.status === 0 ? result.stdout : undefined;
|
|
224
|
+
return gitStdout(root, ["show", `${ref}:./${file}`]);
|
|
210
225
|
}
|
|
211
226
|
function isSpecPath(file) {
|
|
212
227
|
return file === "SPEC.md" || file.endsWith("/SPEC.md");
|
package/dist/update.js
CHANGED
|
@@ -3,6 +3,7 @@ import { initConfig, readConfig } from "./config.js";
|
|
|
3
3
|
import { doctor } from "./doctor.js";
|
|
4
4
|
import { detectPackageManager, installCommandFor } from "./init.js";
|
|
5
5
|
import { materializeLenses } from "./lenses.js";
|
|
6
|
+
import { scaffoldReviewWorkflow } from "./reviewinit.js";
|
|
6
7
|
import { materializeSkills } from "./skills.js";
|
|
7
8
|
/** The global-install one-liner. npm is the portable choice: it ships with Node. */
|
|
8
9
|
const BINARY_UPDATE = "npm install -g speccle@latest";
|
|
@@ -13,6 +14,9 @@ const BINARY_UPDATE = "npm install -g speccle@latest";
|
|
|
13
14
|
* left alone — while the strength stack and global binary are reported, never rewritten: the
|
|
14
15
|
* ticket's principle that only the binary may update silently, and it does so through the
|
|
15
16
|
* printed command, not through this deterministic tool.
|
|
17
|
+
*
|
|
18
|
+
* The CI driver moves only if it is already there (#187): the workflow spends a metered API
|
|
19
|
+
* key per run, so scaffolding one into a repo that never asked would be opting it in silently.
|
|
16
20
|
*/
|
|
17
21
|
export async function update(target) {
|
|
18
22
|
const root = resolve(target);
|
|
@@ -24,6 +28,9 @@ export async function update(target) {
|
|
|
24
28
|
const materializedSkills = await materializeSkills(root);
|
|
25
29
|
const materializedLenses = await materializeLenses(root);
|
|
26
30
|
await initConfig(root, version); // re-stamp both anchors; the repo facts stay kept
|
|
31
|
+
const hasDriver = diagnosis.driver.status !== "absent";
|
|
32
|
+
if (hasDriver)
|
|
33
|
+
await scaffoldReviewWorkflow(root, version);
|
|
27
34
|
const outstanding = diagnosis.stack.deps.filter((dep) => dep.status !== "ok");
|
|
28
35
|
const fixCommand = diagnosis.stack.status === "drift"
|
|
29
36
|
? installCommandFor(await detectPackageManager(root), outstanding.map((dep) => `${dep.name}@^${dep.wantedMajor}`))
|
|
@@ -43,6 +50,7 @@ export async function update(target) {
|
|
|
43
50
|
dir: materializedLenses.dir,
|
|
44
51
|
lenses: materializedLenses.lenses,
|
|
45
52
|
},
|
|
53
|
+
driver: { from: diagnosis.driver.recorded, to: hasDriver ? version : null },
|
|
46
54
|
stack: { status: diagnosis.stack.status, deps: diagnosis.stack.deps, fixCommand },
|
|
47
55
|
};
|
|
48
56
|
}
|
package/package.json
CHANGED