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 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 and the strength stack still
24
- match this CLI; `update` refreshes the skills forward and prints the CLI + stack fix
25
- commands. See [Install](https://github.com/matthewalton/speccle/blob/main/README.md#install)
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
- Init also warns (best-effort, via `~/.claude/settings.json`) when the target vendors
429
- the speccle skills project-level in `.claude/skills/` while a user-level speccle plugin
430
- is still enabled two copies of every skill would load.
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 = git(root, args);
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 = git(root, ["merge-base", base, "HEAD"])?.trim();
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 = git(root, ["diff", "--name-only", mergeBase, "HEAD"]);
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 source
12
- // of truth, falling back to the default only when the repo has recorded nothing.
13
- const declared = options.dialect ?? (await readConfig(root))?.dialect;
14
- const dialect = resolveDialect(declared ?? DEFAULT_DIALECT);
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 found = new Set();
29
- for (const folder of folders) {
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, dialect)) {
32
- found.add(folder === "." ? file : `${folder}/${file}`);
39
+ for (const file of await discoverTests(abs, folderDialect)) {
40
+ fileDialects.set(folder === "." ? file : `${folder}/${file}`, folderDialect);
33
41
  }
34
42
  }
35
- const testFiles = [...found].sort();
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 dialect.readTestNames(source)) {
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
- dialect: dialect.name,
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, and strength stack
26
- update [path] [--json] Refresh the skills and lenses as a diff; report stack and binary fixes
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 the three things that drift in a Speccle consumer — the CLI, the
9
- * committed skills, and the strength stack — and never mutates a byte (#182, ADR-0046).
10
- * Offline and deterministic: it compares the repo against the installed CLI, not against
11
- * the npm registry — "is there a newer release" is `update`'s network job, not this one'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) && current(lensesStatus) && stackStatus !== "drift";
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 ${report.dialect} dialect`);
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 = `${report.dialect} — ${specs}, ${criteria}, ${claimed} claimed`;
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("warning: this repo vendors the speccle skills project-level AND the");
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
- const declared = options.dialect ?? (await readConfig(root))?.dialect;
34
- const dialect = resolveDialect(declared ?? DEFAULT_DIALECT);
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, dialect));
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.name));
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
- /** Governed-slice production source that changed while the slice's SPEC.md stayed silent. */
86
- async function specSilentChanges(root, changed, dialect) {
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) || dialect.isTestFile(file))
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
- /** Unclaimed criterion ids in the governed slices this change touched. */
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
- const result = spawnSync("git", ["show", `${ref}:./${file}`], { cwd: root, encoding: "utf8" });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speccle",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Speccle's deterministic tooling: spec lint and the oracle-strength heatmap. No LLM, bar the opt-in CI review driver.",
5
5
  "license": "MIT",
6
6
  "author": {