speccle 0.11.0 → 0.12.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 +10 -1
- package/dist/claims.js +5 -1
- package/dist/cli.js +118 -3
- package/dist/config.js +114 -0
- package/dist/doctor.js +127 -0
- package/dist/init.js +5 -4
- package/dist/render.js +106 -0
- package/dist/skills.js +63 -0
- package/dist/update.js +39 -0
- package/package.json +4 -3
- package/skills/carve-feature/SKILL.md +183 -0
- package/skills/carve-feature/references/convention.md +239 -0
- package/skills/conform/SKILL.md +129 -0
- package/skills/conform/references/convention.md +239 -0
- package/skills/feature/SKILL.md +103 -0
- package/skills/implement-feature/SKILL.md +152 -0
- package/skills/implement-feature/references/convention.md +239 -0
- package/skills/plan-feature/SKILL.md +125 -0
- package/skills/plan-feature/references/convention.md +239 -0
- package/skills/spec-feature/SKILL.md +163 -0
- package/skills/spec-feature/references/convention.md +239 -0
- package/skills/strengthen/SKILL.md +184 -0
- package/skills/strengthen/references/heatmap.md +39 -0
- package/skills/strengthen/references/stack.md +23 -0
package/README.md
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
# speccle
|
|
2
2
|
|
|
3
|
-
The deterministic tooling the skills invoke
|
|
3
|
+
The deterministic tooling the skills invoke — one bin:
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
+
speccle init # record repo facts + vendor the skills into .claude/skills/
|
|
7
|
+
speccle doctor # report staleness across the CLI, skills, and strength stack
|
|
8
|
+
speccle update # refresh the vendored skills; print the CLI + stack fix commands
|
|
6
9
|
speccle lint # enforce the convention over a repo's specs
|
|
7
10
|
speccle claims # join criteria to the test names that claim them
|
|
8
11
|
speccle strength # oracle-strength heatmap: per-criterion killed ÷ covered
|
|
9
12
|
speccle strength init # provision the strength stack into a target
|
|
10
13
|
```
|
|
11
14
|
|
|
15
|
+
- `init` / `doctor` / `update` — the setup and staleness surface: `init` records the
|
|
16
|
+
repo's test facts in `.speccle/config.json` and vendors the skills into
|
|
17
|
+
`.claude/skills/`; `doctor` reports whether those skills and the strength stack still
|
|
18
|
+
match this CLI; `update` refreshes the skills forward and prints the CLI + stack fix
|
|
19
|
+
commands. See [Install](https://github.com/matthewalton/speccle/blob/main/README.md#install)
|
|
20
|
+
and [Updating](https://github.com/matthewalton/speccle/blob/main/README.md#updating).
|
|
12
21
|
- `lint` — enforce the [convention](https://github.com/matthewalton/speccle/blob/main/docs/convention.md) over a repo's specs.
|
|
13
22
|
- `claims` — join every criterion to the test names carrying its id, statically. No
|
|
14
23
|
reports needed, so it is cheap enough to gate on.
|
package/dist/claims.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
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
4
|
import { DEFAULT_DIALECT, resolveDialect } from "./dialects.js";
|
|
4
5
|
import { discoverSpecs, discoverTests } from "./discover.js";
|
|
5
6
|
import { compareCriterionIds, parseSpec, readClaimedIds } from "./spec.js";
|
|
6
7
|
export async function claims(target, options = {}) {
|
|
7
|
-
const dialect = resolveDialect(options.dialect ?? DEFAULT_DIALECT);
|
|
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
15
|
const specFiles = await discoverSpecs(root);
|
|
12
16
|
const specs = await Promise.all(specFiles.map(async (file) => parseSpec(await readFile(join(root, file), "utf8"), file)));
|
|
13
17
|
const criteria = new Map();
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { check } from "./check.js";
|
|
3
3
|
import { claims } from "./claims.js";
|
|
4
|
+
import { initConfig } from "./config.js";
|
|
4
5
|
import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
|
|
5
|
-
import {
|
|
6
|
+
import { doctor } from "./doctor.js";
|
|
7
|
+
import { init, ownVersion } from "./init.js";
|
|
6
8
|
import { lint } from "./lint.js";
|
|
7
|
-
import { renderCheck, renderClaims, renderHuman, renderInit, renderStrength } from "./render.js";
|
|
9
|
+
import { renderCheck, renderClaims, renderConfigInit, renderDoctor, renderHuman, renderInit, renderSkillsInit, renderStrength, renderUpdate, } from "./render.js";
|
|
10
|
+
import { materializeSkills } from "./skills.js";
|
|
8
11
|
import { DEFAULT_COVERAGE_SUMMARY, DEFAULT_MUTATION_REPORT, strength } from "./strength.js";
|
|
12
|
+
import { update } from "./update.js";
|
|
9
13
|
const USAGE = `Usage: speccle <command> [options]
|
|
10
14
|
|
|
11
15
|
Commands:
|
|
16
|
+
init [path] [--json] Record repo facts in .speccle/config.json and materialize
|
|
17
|
+
the skills into .claude/skills/
|
|
18
|
+
doctor [path] [--json] Report staleness across the CLI, skills, and strength stack
|
|
19
|
+
update [path] [--json] Refresh the skills as a diff; report the stack and binary fixes
|
|
12
20
|
lint [path] [--json] Lint every SPEC.md under path (default: current directory)
|
|
13
21
|
claims [path] [--json] Join criteria to the test names that claim them — no reports needed
|
|
14
22
|
strength [path] [--json] Oracle-strength heatmap: per-criterion killed ÷ covered
|
|
15
23
|
strength init [path] [--json] Provision the strength stack: devDependencies + configs
|
|
24
|
+
--version, -v Print the installed CLI version
|
|
16
25
|
|
|
17
26
|
claims options:
|
|
18
27
|
--dialect <name> Test dialect: ${DIALECT_NAMES.join(", ")} (default: ${DEFAULT_DIALECT})
|
|
@@ -30,12 +39,22 @@ strength init options:
|
|
|
30
39
|
Exit codes: 0 clean, 1 violations, 2 usage error`;
|
|
31
40
|
async function main(argv) {
|
|
32
41
|
const [command, ...rest] = argv;
|
|
42
|
+
if (command === "--version" || command === "-v") {
|
|
43
|
+
console.log(await ownVersion());
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
if (command === "init")
|
|
47
|
+
return runInit(rest);
|
|
48
|
+
if (command === "doctor")
|
|
49
|
+
return runDoctor(rest);
|
|
50
|
+
if (command === "update")
|
|
51
|
+
return runUpdate(rest);
|
|
33
52
|
if (command === "lint")
|
|
34
53
|
return runLint(rest);
|
|
35
54
|
if (command === "claims")
|
|
36
55
|
return runClaims(rest);
|
|
37
56
|
if (command === "strength" && rest[0] === "init")
|
|
38
|
-
return
|
|
57
|
+
return runStrengthInit(rest.slice(1));
|
|
39
58
|
if (command === "strength")
|
|
40
59
|
return runStrength(rest);
|
|
41
60
|
console.error(USAGE);
|
|
@@ -79,6 +98,62 @@ async function runClaims(args) {
|
|
|
79
98
|
console.log(json ? JSON.stringify(report, null, 2) : renderClaims(report));
|
|
80
99
|
return report.clean ? 0 : 1;
|
|
81
100
|
}
|
|
101
|
+
async function runDoctor(args) {
|
|
102
|
+
let json = false;
|
|
103
|
+
const positional = [];
|
|
104
|
+
for (const arg of args) {
|
|
105
|
+
if (arg === "--json")
|
|
106
|
+
json = true;
|
|
107
|
+
else if (arg.startsWith("-")) {
|
|
108
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
positional.push(arg);
|
|
113
|
+
}
|
|
114
|
+
if (positional.length > 1) {
|
|
115
|
+
console.error(`doctor takes at most one path\n\n${USAGE}`);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
let report;
|
|
119
|
+
try {
|
|
120
|
+
report = await doctor(positional[0] ?? ".");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(message(err));
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderDoctor(report));
|
|
127
|
+
return report.ok ? 0 : 1;
|
|
128
|
+
}
|
|
129
|
+
async function runUpdate(args) {
|
|
130
|
+
let json = false;
|
|
131
|
+
const positional = [];
|
|
132
|
+
for (const arg of args) {
|
|
133
|
+
if (arg === "--json")
|
|
134
|
+
json = true;
|
|
135
|
+
else if (arg.startsWith("-")) {
|
|
136
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
137
|
+
return 2;
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
positional.push(arg);
|
|
141
|
+
}
|
|
142
|
+
if (positional.length > 1) {
|
|
143
|
+
console.error(`update takes at most one path\n\n${USAGE}`);
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
let report;
|
|
147
|
+
try {
|
|
148
|
+
report = await update(positional[0] ?? ".");
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
console.error(message(err));
|
|
152
|
+
return 2;
|
|
153
|
+
}
|
|
154
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderUpdate(report));
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
82
157
|
async function runLint(args) {
|
|
83
158
|
let json = false;
|
|
84
159
|
const positional = [];
|
|
@@ -172,6 +247,46 @@ async function runStrength(args) {
|
|
|
172
247
|
return 0;
|
|
173
248
|
}
|
|
174
249
|
async function runInit(args) {
|
|
250
|
+
let json = false;
|
|
251
|
+
const positional = [];
|
|
252
|
+
for (const arg of args) {
|
|
253
|
+
if (arg === "--json")
|
|
254
|
+
json = true;
|
|
255
|
+
else if (arg.startsWith("-")) {
|
|
256
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
else
|
|
260
|
+
positional.push(arg);
|
|
261
|
+
}
|
|
262
|
+
if (positional.length > 1) {
|
|
263
|
+
console.error(`init takes at most one path\n\n${USAGE}`);
|
|
264
|
+
return 2;
|
|
265
|
+
}
|
|
266
|
+
const root = positional[0] ?? ".";
|
|
267
|
+
let config;
|
|
268
|
+
let skills;
|
|
269
|
+
try {
|
|
270
|
+
// Materialize first, then stamp the version onto the config — so the recorded anchor
|
|
271
|
+
// only ever names skills that actually landed on disk.
|
|
272
|
+
skills = await materializeSkills(root);
|
|
273
|
+
config = await initConfig(root, await ownVersion());
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
console.error(message(err));
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
if (json) {
|
|
280
|
+
console.log(JSON.stringify({ config, skills }, null, 2));
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
console.log(renderConfigInit(config));
|
|
284
|
+
console.log("");
|
|
285
|
+
console.log(renderSkillsInit(skills));
|
|
286
|
+
}
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
async function runStrengthInit(args) {
|
|
175
290
|
let json = false;
|
|
176
291
|
let skipInstall = false;
|
|
177
292
|
const mutate = [];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
|
|
4
|
+
import { detectPackageManager } from "./init.js";
|
|
5
|
+
/** Where Speccle reads a repo's facts (ADR-0040). */
|
|
6
|
+
export const CONFIG_DIR = ".speccle";
|
|
7
|
+
export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
|
|
8
|
+
/** Reads `.speccle/config.json`; undefined when the repo has no config yet. */
|
|
9
|
+
export async function readConfig(root) {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = await readFile(join(root, CONFIG_FILE), "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
let config;
|
|
18
|
+
try {
|
|
19
|
+
config = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
throw new Error(`${CONFIG_FILE} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
23
|
+
}
|
|
24
|
+
// A wrong dialect must be a loud, visible failure — that is the whole point of writing
|
|
25
|
+
// it down rather than guessing every time (ADR-0040).
|
|
26
|
+
assertDialect(config.dialect);
|
|
27
|
+
for (const override of config.overrides ?? []) {
|
|
28
|
+
if (override.dialect !== undefined)
|
|
29
|
+
assertDialect(override.dialect);
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The dialect and suite in force at a root-relative path. Every override whose path is a
|
|
35
|
+
* prefix of `path` applies, most-specific last, so a longer override wins per field and a
|
|
36
|
+
* shorter one still fills a field the longer omits.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveFacts(config, path) {
|
|
39
|
+
let { dialect, suite } = config;
|
|
40
|
+
const matching = (config.overrides ?? [])
|
|
41
|
+
.filter((override) => underPath(path, override.path))
|
|
42
|
+
.sort((a, b) => a.path.length - b.path.length);
|
|
43
|
+
for (const override of matching) {
|
|
44
|
+
if (override.dialect !== undefined)
|
|
45
|
+
dialect = override.dialect;
|
|
46
|
+
if (override.suite !== undefined)
|
|
47
|
+
suite = override.suite;
|
|
48
|
+
}
|
|
49
|
+
return { dialect, suite };
|
|
50
|
+
}
|
|
51
|
+
/** Speccle's first guess at a repo's facts — written down, then corrected by hand. */
|
|
52
|
+
export async function detectConfig(root) {
|
|
53
|
+
const dialect = await detectDialect(root);
|
|
54
|
+
return { dialect, suite: await detectSuite(root, dialect) };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Writes `.speccle/config.json` from detection, or keeps an existing one untouched — the
|
|
58
|
+
* written record is the source of truth, never the detection (ADR-0040), the same posture
|
|
59
|
+
* `strength init` takes with the stack it provisions.
|
|
60
|
+
*
|
|
61
|
+
* When `skillsVersion` is given, it is (re)stamped even on the kept path: materialization
|
|
62
|
+
* always refreshes the skills to the current version, so the anchor must move with them or
|
|
63
|
+
* a re-run would leave the recorded version behind the files it just wrote. The facts
|
|
64
|
+
* themselves stay kept — only the stamp follows the skills.
|
|
65
|
+
*/
|
|
66
|
+
export async function initConfig(root, skillsVersion) {
|
|
67
|
+
const existing = await readConfig(root);
|
|
68
|
+
if (existing !== undefined) {
|
|
69
|
+
const config = skillsVersion !== undefined ? { ...existing, skillsVersion } : existing;
|
|
70
|
+
if (skillsVersion !== undefined && existing.skillsVersion !== skillsVersion) {
|
|
71
|
+
await writeConfig(root, config);
|
|
72
|
+
}
|
|
73
|
+
return { root, file: CONFIG_FILE, action: "kept", config };
|
|
74
|
+
}
|
|
75
|
+
const detected = await detectConfig(root);
|
|
76
|
+
const config = skillsVersion !== undefined ? { ...detected, skillsVersion } : detected;
|
|
77
|
+
await writeConfig(root, config);
|
|
78
|
+
return { root, file: CONFIG_FILE, action: "written", config };
|
|
79
|
+
}
|
|
80
|
+
export async function writeConfig(root, config) {
|
|
81
|
+
await mkdir(join(root, CONFIG_DIR), { recursive: true });
|
|
82
|
+
await writeFile(join(root, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
|
|
83
|
+
}
|
|
84
|
+
async function detectDialect(root) {
|
|
85
|
+
if (await exists(join(root, "Package.swift")))
|
|
86
|
+
return "swift";
|
|
87
|
+
// package.json or nothing: the TypeScript stack is the default dialect.
|
|
88
|
+
return DEFAULT_DIALECT;
|
|
89
|
+
}
|
|
90
|
+
// The default suite runs the repo's own `test` script, except a swift repo, whose
|
|
91
|
+
// SwiftPM default cannot know an xcodebuild scheme (ADR-0040) — the human corrects it.
|
|
92
|
+
async function detectSuite(root, dialect) {
|
|
93
|
+
if (dialect === "swift")
|
|
94
|
+
return "swift test";
|
|
95
|
+
const packageManager = await detectPackageManager(root);
|
|
96
|
+
return packageManager === "bun" ? "bun run test" : `${packageManager} test`;
|
|
97
|
+
}
|
|
98
|
+
function assertDialect(name) {
|
|
99
|
+
if (!DIALECT_NAMES.includes(name)) {
|
|
100
|
+
throw new Error(`${CONFIG_FILE}: unknown test dialect: ${name} — known dialects: ${DIALECT_NAMES.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function underPath(path, prefix) {
|
|
104
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
105
|
+
}
|
|
106
|
+
async function exists(path) {
|
|
107
|
+
try {
|
|
108
|
+
await access(path);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { readConfig } from "./config.js";
|
|
4
|
+
import { ownVersion, STRENGTH_DEPS, STRYKER_CONFIG_NAMES } from "./init.js";
|
|
5
|
+
import { SKILLS_DIR } from "./skills.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reports the truth about the three things that drift in a Speccle consumer — the CLI, the
|
|
8
|
+
* committed skills, and the strength stack — and never mutates a byte (#182, ADR-0046).
|
|
9
|
+
* Offline and deterministic: it compares the repo against the installed CLI, not against
|
|
10
|
+
* the npm registry — "is there a newer release" is `update`'s network job, not this one's.
|
|
11
|
+
*/
|
|
12
|
+
export async function doctor(target) {
|
|
13
|
+
const root = resolve(target);
|
|
14
|
+
if (!(await isDirectory(root)))
|
|
15
|
+
throw new Error(`path not found: ${target}`);
|
|
16
|
+
const cli = await ownVersion();
|
|
17
|
+
const config = await readConfig(root);
|
|
18
|
+
const recorded = config?.skillsVersion ?? null;
|
|
19
|
+
const skillsStatus = deriveSkillsStatus(await hasSkills(root), recorded, cli);
|
|
20
|
+
const provisioned = await anyPresent(root, STRYKER_CONFIG_NAMES);
|
|
21
|
+
const declared = await declaredDeps(root);
|
|
22
|
+
const deps = STRENGTH_DEPS.map((spec) => checkDep(spec, declared));
|
|
23
|
+
const stackStatus = !provisioned
|
|
24
|
+
? "absent"
|
|
25
|
+
: deps.some((dep) => dep.status !== "ok")
|
|
26
|
+
? "drift"
|
|
27
|
+
: "current";
|
|
28
|
+
const ok = (skillsStatus === "current" || skillsStatus === "absent") && stackStatus !== "drift";
|
|
29
|
+
return {
|
|
30
|
+
root,
|
|
31
|
+
cli,
|
|
32
|
+
skills: { recorded, bundled: cli, status: skillsStatus },
|
|
33
|
+
stack: { provisioned, deps, status: stackStatus },
|
|
34
|
+
ok,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function deriveSkillsStatus(present, recorded, bundled) {
|
|
38
|
+
if (!present)
|
|
39
|
+
return "absent";
|
|
40
|
+
if (recorded === null)
|
|
41
|
+
return "unstamped";
|
|
42
|
+
const order = compareVersions(recorded, bundled);
|
|
43
|
+
return order === 0 ? "current" : order < 0 ? "stale" : "ahead";
|
|
44
|
+
}
|
|
45
|
+
function checkDep(spec, declared) {
|
|
46
|
+
const at = spec.lastIndexOf("@");
|
|
47
|
+
const name = spec.slice(0, at);
|
|
48
|
+
const wantedMajor = majorOf(spec.slice(at + 1)) ?? 0;
|
|
49
|
+
const range = declared.get(name);
|
|
50
|
+
if (range === undefined)
|
|
51
|
+
return { name, wantedMajor, declared: null, status: "missing" };
|
|
52
|
+
const declaredMajor = majorOf(range);
|
|
53
|
+
// A repo ahead of the preset is the consumer's choice, not drift to nag about; only a
|
|
54
|
+
// major below the preset is the "bump never propagated" bug this surface exists to catch.
|
|
55
|
+
const status = declaredMajor !== null && declaredMajor < wantedMajor ? "behind" : "ok";
|
|
56
|
+
return { name, wantedMajor, declared: range, status };
|
|
57
|
+
}
|
|
58
|
+
/** The union of a package.json's dependencies and devDependencies; empty when absent. */
|
|
59
|
+
async function declaredDeps(root) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = await readFile(join(root, "package.json"), "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return new Map();
|
|
66
|
+
}
|
|
67
|
+
let pkg;
|
|
68
|
+
try {
|
|
69
|
+
pkg = JSON.parse(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return new Map();
|
|
73
|
+
}
|
|
74
|
+
return new Map(Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }));
|
|
75
|
+
}
|
|
76
|
+
async function hasSkills(root) {
|
|
77
|
+
try {
|
|
78
|
+
const entries = await readdir(join(root, SKILLS_DIR), { withFileTypes: true });
|
|
79
|
+
return entries.some((entry) => entry.isDirectory());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function anyPresent(root, names) {
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
if (await exists(join(root, name)))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** The leading integer of a version or range — 4 from "^4", "4.1.0", ">=4.0.0". */
|
|
93
|
+
function majorOf(range) {
|
|
94
|
+
const digits = /\d+/.exec(range);
|
|
95
|
+
return digits === null ? null : Number(digits[0]);
|
|
96
|
+
}
|
|
97
|
+
function compareVersions(a, b) {
|
|
98
|
+
const [pa, pb] = [parseVersion(a), parseVersion(b)];
|
|
99
|
+
for (let i = 0; i < 3; i++) {
|
|
100
|
+
if (pa[i] !== pb[i])
|
|
101
|
+
return pa[i] < pb[i] ? -1 : 1;
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
/** [major, minor, patch] of a clean semver, prerelease and build metadata dropped. */
|
|
106
|
+
function parseVersion(version) {
|
|
107
|
+
const core = version.split(/[-+]/)[0] ?? "";
|
|
108
|
+
const parts = core.split(".").map((part) => Number(part) || 0);
|
|
109
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
110
|
+
}
|
|
111
|
+
async function isDirectory(path) {
|
|
112
|
+
try {
|
|
113
|
+
return (await stat(path)).isDirectory();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function exists(path) {
|
|
120
|
+
try {
|
|
121
|
+
await access(path);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -17,7 +17,7 @@ const LOCKFILES = [
|
|
|
17
17
|
["bun.lock", "bun"],
|
|
18
18
|
["bun.lockb", "bun"],
|
|
19
19
|
];
|
|
20
|
-
const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
|
|
20
|
+
export const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
|
|
21
21
|
const VITEST_CONFIG_NAMES = ["vitest.config", "vite.config"].flatMap((base) => ["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
|
|
22
22
|
export function strykerConfig(packageManager, mutate) {
|
|
23
23
|
return {
|
|
@@ -109,7 +109,8 @@ export async function detectDoubleLoad(root, settingsFile = join(homedir(), ".cl
|
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
/** The installed CLI's own version — the `speccle@X` that ships this bin and its skills. */
|
|
113
|
+
export async function ownVersion() {
|
|
113
114
|
const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
114
115
|
return JSON.parse(raw).version;
|
|
115
116
|
}
|
|
@@ -123,7 +124,7 @@ async function readPackageJson(root) {
|
|
|
123
124
|
}
|
|
124
125
|
return JSON.parse(raw);
|
|
125
126
|
}
|
|
126
|
-
async function detectPackageManager(root) {
|
|
127
|
+
export async function detectPackageManager(root) {
|
|
127
128
|
for (const [lockfile, manager] of LOCKFILES) {
|
|
128
129
|
if (await exists(join(root, lockfile)))
|
|
129
130
|
return manager;
|
|
@@ -138,7 +139,7 @@ async function provision(root, existingNames, writeName, content) {
|
|
|
138
139
|
await writeFile(join(root, writeName), content());
|
|
139
140
|
return { file: writeName, action: "written" };
|
|
140
141
|
}
|
|
141
|
-
function installCommandFor(manager, deps) {
|
|
142
|
+
export function installCommandFor(manager, deps) {
|
|
142
143
|
const subcommand = manager === "npm" ? "install -D" : manager === "bun" ? "add -d" : "add -D";
|
|
143
144
|
return `${manager} ${subcommand} ${deps.join(" ")}`;
|
|
144
145
|
}
|
package/dist/render.js
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
+
export function renderConfigInit(report) {
|
|
2
|
+
const lines = [
|
|
3
|
+
report.action === "written"
|
|
4
|
+
? `wrote ${report.file}`
|
|
5
|
+
: `kept ${report.file} — already present, left untouched`,
|
|
6
|
+
` dialect ${report.config.dialect}`,
|
|
7
|
+
` suite ${report.config.suite}`,
|
|
8
|
+
];
|
|
9
|
+
for (const override of report.config.overrides ?? []) {
|
|
10
|
+
const facts = [
|
|
11
|
+
override.dialect !== undefined ? `dialect ${override.dialect}` : undefined,
|
|
12
|
+
override.suite !== undefined ? `suite ${override.suite}` : undefined,
|
|
13
|
+
].filter((fact) => fact !== undefined);
|
|
14
|
+
lines.push(` ${override.path}: ${facts.join(", ")}`);
|
|
15
|
+
}
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push("these are facts about your repo, not judgement — edit .speccle/config.json by hand");
|
|
18
|
+
if (report.config.dialect === "swift" && report.config.suite === "swift test") {
|
|
19
|
+
lines.push("swift: set suite to your xcodebuild scheme if this is not a SwiftPM package");
|
|
20
|
+
}
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
export function renderSkillsInit(report) {
|
|
24
|
+
const lines = [`materialized ${plural(report.skills.length, "skill")} into ${report.dir}/`];
|
|
25
|
+
for (const { name, action } of report.skills) {
|
|
26
|
+
lines.push(` ${(action === "written" ? "wrote" : "refreshed").padEnd(9)} ${name}`);
|
|
27
|
+
}
|
|
28
|
+
lines.push("");
|
|
29
|
+
lines.push("these are generated files — commit them; re-run `speccle init` to refresh");
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
export function renderDoctor(report) {
|
|
33
|
+
const lines = [
|
|
34
|
+
`speccle ${report.cli}`,
|
|
35
|
+
"",
|
|
36
|
+
`skills ${describeSkills(report.skills)}`,
|
|
37
|
+
`stack ${describeStack(report.stack)}`,
|
|
38
|
+
];
|
|
39
|
+
if (report.stack.status === "drift") {
|
|
40
|
+
const width = Math.max(...report.stack.deps.map((dep) => dep.name.length));
|
|
41
|
+
for (const dep of report.stack.deps) {
|
|
42
|
+
if (dep.status === "ok")
|
|
43
|
+
continue;
|
|
44
|
+
lines.push(` ${dep.name.padEnd(width)} ${describeDep(dep)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lines.push("");
|
|
48
|
+
if (report.skills.status === "absent" && report.stack.status === "absent") {
|
|
49
|
+
lines.push("Speccle is not set up here — run `speccle init`");
|
|
50
|
+
}
|
|
51
|
+
else if (report.ok) {
|
|
52
|
+
lines.push("up to date");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
lines.push("out of date — run `speccle update`");
|
|
56
|
+
}
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
function describeSkills(skills) {
|
|
60
|
+
switch (skills.status) {
|
|
61
|
+
case "current":
|
|
62
|
+
return `current (${skills.bundled})`;
|
|
63
|
+
case "stale":
|
|
64
|
+
return `stale — committed ${skills.recorded}, this CLI ships ${skills.bundled}`;
|
|
65
|
+
case "ahead":
|
|
66
|
+
return `ahead — committed ${skills.recorded} is newer than this CLI (${skills.bundled})`;
|
|
67
|
+
case "unstamped":
|
|
68
|
+
return "present but unversioned — re-run `speccle init` to record the version";
|
|
69
|
+
case "absent":
|
|
70
|
+
return "not materialized — run `speccle init`";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function describeStack(stack) {
|
|
74
|
+
if (stack.status === "absent")
|
|
75
|
+
return "not provisioned — run `speccle strength init`";
|
|
76
|
+
if (stack.status === "current")
|
|
77
|
+
return "current";
|
|
78
|
+
return "drift — the stack is behind the current preset";
|
|
79
|
+
}
|
|
80
|
+
function describeDep(dep) {
|
|
81
|
+
return dep.status === "missing"
|
|
82
|
+
? `missing — preset wants ^${dep.wantedMajor}`
|
|
83
|
+
: `behind — has ${dep.declared}, preset wants ^${dep.wantedMajor}`;
|
|
84
|
+
}
|
|
85
|
+
export function renderUpdate(report) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
if (report.skills.from === report.skills.to) {
|
|
88
|
+
lines.push(`skills already at ${report.skills.to} — refreshed in place; review the diff`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
lines.push(`skills ${report.skills.from ?? "unversioned"} → ${report.skills.to} — review & commit the diff`);
|
|
92
|
+
}
|
|
93
|
+
if (report.stack.status === "absent") {
|
|
94
|
+
lines.push("stack not provisioned — run `speccle strength init`");
|
|
95
|
+
}
|
|
96
|
+
else if (report.stack.status === "current") {
|
|
97
|
+
lines.push("stack up to date");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push("stack behind the preset — run:");
|
|
101
|
+
lines.push(` ${report.stack.fixCommand}`);
|
|
102
|
+
}
|
|
103
|
+
lines.push(`cli ${report.cli.version} installed — update the binary with:`);
|
|
104
|
+
lines.push(` ${report.cli.command}`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
1
107
|
export function renderCheck(report) {
|
|
2
108
|
const lines = [
|
|
3
109
|
`mutation ${describeReport(report.mutation)}`,
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { access, cp, mkdir, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** Where materialized skills land in a target repo — the path Claude Code loads them from. */
|
|
5
|
+
export const SKILLS_DIR = ".claude/skills";
|
|
6
|
+
/**
|
|
7
|
+
* Copies every bundled skill into the target's `.claude/skills/<name>/`, refreshing an
|
|
8
|
+
* existing copy rather than merging into it, so the materialized tree mirrors the tarball
|
|
9
|
+
* exactly and a re-run's git diff is the whole change a human reviews (ADR-0046, ADR-0040).
|
|
10
|
+
* Only the skills Speccle ships are touched, by name; any other skill in the folder is left
|
|
11
|
+
* alone.
|
|
12
|
+
*/
|
|
13
|
+
export async function materializeSkills(root, source) {
|
|
14
|
+
const from = source ?? (await bundledSkillsDir());
|
|
15
|
+
const names = await skillNames(from);
|
|
16
|
+
const target = join(root, SKILLS_DIR);
|
|
17
|
+
await mkdir(target, { recursive: true });
|
|
18
|
+
const skills = [];
|
|
19
|
+
for (const name of names) {
|
|
20
|
+
const dest = join(target, name);
|
|
21
|
+
const existed = await exists(dest);
|
|
22
|
+
await rm(dest, { recursive: true, force: true });
|
|
23
|
+
await cp(join(from, name), dest, { recursive: true });
|
|
24
|
+
skills.push({ name, action: existed ? "refreshed" : "written" });
|
|
25
|
+
}
|
|
26
|
+
return { root, dir: SKILLS_DIR, source: from, skills };
|
|
27
|
+
}
|
|
28
|
+
async function skillNames(source) {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(source, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error(`no bundled skills to materialize at ${source}`);
|
|
35
|
+
}
|
|
36
|
+
return entries
|
|
37
|
+
.filter((entry) => entry.isDirectory())
|
|
38
|
+
.map((entry) => entry.name)
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The shipped skills: a top-level `skills/` beside `dist/` in the published tarball
|
|
43
|
+
* (ADR-0046). Running from source there is no bundled copy yet, so fall back to the
|
|
44
|
+
* plugin's skill dirs — the very files the build copies into the tarball.
|
|
45
|
+
*/
|
|
46
|
+
async function bundledSkillsDir() {
|
|
47
|
+
const bundled = fileURLToPath(new URL("../skills", import.meta.url));
|
|
48
|
+
if (await exists(bundled))
|
|
49
|
+
return bundled;
|
|
50
|
+
const pluginSource = fileURLToPath(new URL("../../plugin/skills", import.meta.url));
|
|
51
|
+
if (await exists(pluginSource))
|
|
52
|
+
return pluginSource;
|
|
53
|
+
throw new Error(`no bundled skills found — expected them beside the CLI at ${bundled}`);
|
|
54
|
+
}
|
|
55
|
+
async function exists(path) {
|
|
56
|
+
try {
|
|
57
|
+
await access(path);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|