speccle 0.12.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 +321 -9
- package/dist/calibration.js +156 -0
- package/dist/changeset.js +136 -0
- package/dist/claims.js +24 -12
- package/dist/cli.js +497 -10
- package/dist/config.js +13 -10
- package/dist/doctor.js +40 -9
- package/dist/git.js +23 -0
- package/dist/init.js +12 -0
- package/dist/lenses.js +68 -0
- package/dist/remedy.js +133 -0
- package/dist/render.js +277 -16
- package/dist/reviewinit.js +139 -0
- package/dist/reviewrun.js +516 -0
- package/dist/risk.js +243 -0
- package/dist/update.js +23 -6
- package/dist/verify.js +92 -0
- package/lenses/accessibility.md +39 -0
- package/lenses/architecture.md +38 -0
- package/lenses/correctness.md +36 -0
- package/lenses/house-conventions.md +37 -0
- package/lenses/performance.md +40 -0
- package/lenses/risk.md +45 -0
- package/lenses/security.md +42 -0
- package/lenses/test-quality.md +39 -0
- package/package.json +5 -4
- package/skills/review/SKILL.md +190 -0
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
|
@@ -1,31 +1,84 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { calibrationReport, recordCalibration } from "./calibration.js";
|
|
2
3
|
import { check } from "./check.js";
|
|
3
4
|
import { claims } from "./claims.js";
|
|
4
5
|
import { initConfig } from "./config.js";
|
|
5
6
|
import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
|
|
6
7
|
import { doctor } from "./doctor.js";
|
|
7
|
-
import { init, ownVersion } from "./init.js";
|
|
8
|
+
import { detectDoubleLoad, init, ownVersion } from "./init.js";
|
|
9
|
+
import { materializeLenses } from "./lenses.js";
|
|
8
10
|
import { lint } from "./lint.js";
|
|
9
|
-
import {
|
|
11
|
+
import { recallRemedy, recordRemedy, REMEDY_ROUTES } from "./remedy.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
|
+
import { scaffoldReviewWorkflow } from "./reviewinit.js";
|
|
14
|
+
import { reviewRun } from "./reviewrun.js";
|
|
15
|
+
import { risk } from "./risk.js";
|
|
10
16
|
import { materializeSkills } from "./skills.js";
|
|
11
17
|
import { DEFAULT_COVERAGE_SUMMARY, DEFAULT_MUTATION_REPORT, strength } from "./strength.js";
|
|
12
18
|
import { update } from "./update.js";
|
|
19
|
+
import { verify } from "./verify.js";
|
|
13
20
|
const USAGE = `Usage: speccle <command> [options]
|
|
14
21
|
|
|
15
22
|
Commands:
|
|
16
|
-
init [path] [--json] Record repo facts in .speccle/config.json
|
|
17
|
-
|
|
18
|
-
doctor [path] [--json] Report staleness across the CLI, skills,
|
|
19
|
-
|
|
23
|
+
init [path] [--json] Record repo facts in .speccle/config.json, materialize the
|
|
24
|
+
skills into .claude/skills/ and the lenses into .speccle/lenses/
|
|
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
|
|
20
29
|
lint [path] [--json] Lint every SPEC.md under path (default: current directory)
|
|
21
30
|
claims [path] [--json] Join criteria to the test names that claim them — no reports needed
|
|
31
|
+
verify [path] [--json] Run .speccle/checks/ against the change set: cross-file invariants
|
|
32
|
+
risk [path] [--json] Score the change set from spec-aware signals; gate on the review threshold
|
|
33
|
+
calibrate record [path] Append a calibration entry: the risk floor + your honest verdict
|
|
34
|
+
calibrate report [path] Read the calibration record: signal reliability + the supported threshold
|
|
35
|
+
remedy record [path] Record a remedy: the finding, the fix, and the prevention artefact
|
|
36
|
+
remedy recall [path] Recall the known remedy for a finding's class — fix consistently
|
|
37
|
+
review init [path] [--json] Scaffold the opt-in CI driver: a GitHub Actions workflow, pinned
|
|
38
|
+
review run [path] Review a pull request with the lens panel and post one review.
|
|
39
|
+
The one command here that calls a model — needs a metered key
|
|
22
40
|
strength [path] [--json] Oracle-strength heatmap: per-criterion killed ÷ covered
|
|
23
41
|
strength init [path] [--json] Provision the strength stack: devDependencies + configs
|
|
24
42
|
--version, -v Print the installed CLI version
|
|
25
43
|
|
|
26
|
-
claims options:
|
|
44
|
+
claims / risk options:
|
|
27
45
|
--dialect <name> Test dialect: ${DIALECT_NAMES.join(", ")} (default: ${DEFAULT_DIALECT})
|
|
28
46
|
|
|
47
|
+
verify / risk options:
|
|
48
|
+
--base <ref> Read the change set from the commits between <ref> and HEAD, instead of the
|
|
49
|
+
working tree's pending change — what a CI driver needs, where the tree is
|
|
50
|
+
clean. Measured from the merge base, so it needs their shared history
|
|
51
|
+
|
|
52
|
+
risk exit codes: 0 below the review threshold (review may fix), 1 at or above it (human required)
|
|
53
|
+
|
|
54
|
+
calibrate record options:
|
|
55
|
+
--needed-human <true|false> Did this change actually need a human? (required — the honest verdict)
|
|
56
|
+
--found-real <true|false> Did the review find something real? (required)
|
|
57
|
+
--escalated A risk lens escalated beyond the deterministic floor
|
|
58
|
+
--note <text> Free-text context for the entry
|
|
59
|
+
--dialect <name> Test dialect: ${DIALECT_NAMES.join(", ")} (default: ${DEFAULT_DIALECT})
|
|
60
|
+
|
|
61
|
+
remedy record options:
|
|
62
|
+
--class <handle> Short, stable handle for the finding's class (required — the recall key)
|
|
63
|
+
--finding <text> What the finding is (required)
|
|
64
|
+
--fix <text> The fix applied to the code (required)
|
|
65
|
+
--route <route> Prevention route: ${REMEDY_ROUTES.join(", ")} (required)
|
|
66
|
+
--artefact <ref> The prevention artefact: a .speccle/checks|lenses path or a SPEC
|
|
67
|
+
criterion id (required for every route but none)
|
|
68
|
+
--note <text> Free-text context for the entry
|
|
69
|
+
|
|
70
|
+
remedy recall options:
|
|
71
|
+
--class <handle> The finding's class to look up (required)
|
|
72
|
+
|
|
73
|
+
review run options:
|
|
74
|
+
--pr <number> The pull request to review (required)
|
|
75
|
+
--repo <owner/name> Defaults to GITHUB_REPOSITORY
|
|
76
|
+
--base <ref> Base ref for the risk verdict (default: origin/<the PR's base>)
|
|
77
|
+
--force Review again even if this driver already reviewed the PR
|
|
78
|
+
--model <id> Overrides SPECCLE_REVIEW_MODEL
|
|
79
|
+
--dry-run Report what would be posted, and post nothing
|
|
80
|
+
Reads ANTHROPIC_API_KEY and GITHUB_TOKEN from the environment
|
|
81
|
+
|
|
29
82
|
strength options:
|
|
30
83
|
--check Report whether the reports are fresh, stale, or missing — never runs them
|
|
31
84
|
--mutation <file> Stryker JSON report (default: ${DEFAULT_MUTATION_REPORT})
|
|
@@ -53,6 +106,34 @@ async function main(argv) {
|
|
|
53
106
|
return runLint(rest);
|
|
54
107
|
if (command === "claims")
|
|
55
108
|
return runClaims(rest);
|
|
109
|
+
if (command === "verify")
|
|
110
|
+
return runVerify(rest);
|
|
111
|
+
if (command === "risk")
|
|
112
|
+
return runRisk(rest);
|
|
113
|
+
if (command === "calibrate" && rest[0] === "record")
|
|
114
|
+
return runCalibrateRecord(rest.slice(1));
|
|
115
|
+
if (command === "calibrate" && rest[0] === "report")
|
|
116
|
+
return runCalibrateReport(rest.slice(1));
|
|
117
|
+
if (command === "calibrate") {
|
|
118
|
+
console.error(`calibrate needs a subcommand: record or report\n\n${USAGE}`);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
if (command === "remedy" && rest[0] === "record")
|
|
122
|
+
return runRemedyRecord(rest.slice(1));
|
|
123
|
+
if (command === "remedy" && rest[0] === "recall")
|
|
124
|
+
return runRemedyRecall(rest.slice(1));
|
|
125
|
+
if (command === "remedy") {
|
|
126
|
+
console.error(`remedy needs a subcommand: record or recall\n\n${USAGE}`);
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
if (command === "review" && rest[0] === "init")
|
|
130
|
+
return runReviewInit(rest.slice(1));
|
|
131
|
+
if (command === "review" && rest[0] === "run")
|
|
132
|
+
return runReviewRun(rest.slice(1));
|
|
133
|
+
if (command === "review") {
|
|
134
|
+
console.error(`review needs a subcommand: init or run\n\n${USAGE}`);
|
|
135
|
+
return 2;
|
|
136
|
+
}
|
|
56
137
|
if (command === "strength" && rest[0] === "init")
|
|
57
138
|
return runStrengthInit(rest.slice(1));
|
|
58
139
|
if (command === "strength")
|
|
@@ -98,6 +179,300 @@ async function runClaims(args) {
|
|
|
98
179
|
console.log(json ? JSON.stringify(report, null, 2) : renderClaims(report));
|
|
99
180
|
return report.clean ? 0 : 1;
|
|
100
181
|
}
|
|
182
|
+
async function runVerify(args) {
|
|
183
|
+
let json = false;
|
|
184
|
+
let base;
|
|
185
|
+
const positional = [];
|
|
186
|
+
for (let i = 0; i < args.length; i++) {
|
|
187
|
+
const arg = args[i];
|
|
188
|
+
if (arg === "--json")
|
|
189
|
+
json = true;
|
|
190
|
+
else if (arg === "--base") {
|
|
191
|
+
const value = args[++i];
|
|
192
|
+
if (value === undefined) {
|
|
193
|
+
console.error(`--base needs a git ref\n\n${USAGE}`);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
base = value;
|
|
197
|
+
}
|
|
198
|
+
else if (arg.startsWith("-")) {
|
|
199
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
positional.push(arg);
|
|
204
|
+
}
|
|
205
|
+
if (positional.length > 1) {
|
|
206
|
+
console.error(`verify takes at most one path\n\n${USAGE}`);
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
let report;
|
|
210
|
+
try {
|
|
211
|
+
report = await verify(positional[0] ?? ".", { ...(base !== undefined && { base }) });
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
console.error(message(err));
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderVerify(report));
|
|
218
|
+
return report.clean ? 0 : 1;
|
|
219
|
+
}
|
|
220
|
+
async function runRisk(args) {
|
|
221
|
+
let json = false;
|
|
222
|
+
let dialect;
|
|
223
|
+
let base;
|
|
224
|
+
const positional = [];
|
|
225
|
+
for (let i = 0; i < args.length; i++) {
|
|
226
|
+
const arg = args[i];
|
|
227
|
+
if (arg === "--json")
|
|
228
|
+
json = true;
|
|
229
|
+
else if (arg === "--dialect" || arg === "--base") {
|
|
230
|
+
const value = args[++i];
|
|
231
|
+
if (value === undefined) {
|
|
232
|
+
console.error(`${arg} needs ${arg === "--base" ? "a git ref" : "a dialect name"}\n\n${USAGE}`);
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
if (arg === "--dialect")
|
|
236
|
+
dialect = value;
|
|
237
|
+
else
|
|
238
|
+
base = value;
|
|
239
|
+
}
|
|
240
|
+
else if (arg.startsWith("-")) {
|
|
241
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
242
|
+
return 2;
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
positional.push(arg);
|
|
246
|
+
}
|
|
247
|
+
if (positional.length > 1) {
|
|
248
|
+
console.error(`risk takes at most one path\n\n${USAGE}`);
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
let report;
|
|
252
|
+
try {
|
|
253
|
+
report = await risk(positional[0] ?? ".", {
|
|
254
|
+
...(dialect !== undefined && { dialect }),
|
|
255
|
+
...(base !== undefined && { base }),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
console.error(message(err));
|
|
260
|
+
return 2;
|
|
261
|
+
}
|
|
262
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderRisk(report));
|
|
263
|
+
return report.humanRequired ? 1 : 0;
|
|
264
|
+
}
|
|
265
|
+
async function runCalibrateRecord(args) {
|
|
266
|
+
let json = false;
|
|
267
|
+
let dialect;
|
|
268
|
+
let neededHuman;
|
|
269
|
+
let foundReal;
|
|
270
|
+
let escalated = false;
|
|
271
|
+
let note;
|
|
272
|
+
const positional = [];
|
|
273
|
+
for (let i = 0; i < args.length; i++) {
|
|
274
|
+
const arg = args[i];
|
|
275
|
+
if (arg === "--json")
|
|
276
|
+
json = true;
|
|
277
|
+
else if (arg === "--escalated")
|
|
278
|
+
escalated = true;
|
|
279
|
+
else if (arg === "--needed-human" || arg === "--found-real") {
|
|
280
|
+
const value = args[++i];
|
|
281
|
+
if (value !== "true" && value !== "false") {
|
|
282
|
+
console.error(`${arg} needs true or false\n\n${USAGE}`);
|
|
283
|
+
return 2;
|
|
284
|
+
}
|
|
285
|
+
if (arg === "--needed-human")
|
|
286
|
+
neededHuman = value === "true";
|
|
287
|
+
else
|
|
288
|
+
foundReal = value === "true";
|
|
289
|
+
}
|
|
290
|
+
else if (arg === "--note" || arg === "--dialect") {
|
|
291
|
+
const value = args[++i];
|
|
292
|
+
if (value === undefined) {
|
|
293
|
+
console.error(`${arg} needs a value\n\n${USAGE}`);
|
|
294
|
+
return 2;
|
|
295
|
+
}
|
|
296
|
+
if (arg === "--note")
|
|
297
|
+
note = value;
|
|
298
|
+
else
|
|
299
|
+
dialect = value;
|
|
300
|
+
}
|
|
301
|
+
else if (arg.startsWith("-")) {
|
|
302
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
303
|
+
return 2;
|
|
304
|
+
}
|
|
305
|
+
else
|
|
306
|
+
positional.push(arg);
|
|
307
|
+
}
|
|
308
|
+
if (positional.length > 1) {
|
|
309
|
+
console.error(`calibrate record takes at most one path\n\n${USAGE}`);
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
// The honest verdict is required, never defaulted — a fabricated verdict is the dishonest
|
|
313
|
+
// calibration data ADR-0042 exists to keep out of the record.
|
|
314
|
+
if (neededHuman === undefined || foundReal === undefined) {
|
|
315
|
+
console.error(`calibrate record needs --needed-human and --found-real\n\n${USAGE}`);
|
|
316
|
+
return 2;
|
|
317
|
+
}
|
|
318
|
+
let report;
|
|
319
|
+
try {
|
|
320
|
+
report = await recordCalibration(positional[0] ?? ".", { neededHuman, foundReal, escalated, ...(note !== undefined && { note }) }, { ...(dialect !== undefined && { dialect }) });
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
console.error(message(err));
|
|
324
|
+
return 2;
|
|
325
|
+
}
|
|
326
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderCalibrateRecord(report));
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
async function runCalibrateReport(args) {
|
|
330
|
+
let json = false;
|
|
331
|
+
const positional = [];
|
|
332
|
+
for (const arg of args) {
|
|
333
|
+
if (arg === "--json")
|
|
334
|
+
json = true;
|
|
335
|
+
else if (arg.startsWith("-")) {
|
|
336
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
337
|
+
return 2;
|
|
338
|
+
}
|
|
339
|
+
else
|
|
340
|
+
positional.push(arg);
|
|
341
|
+
}
|
|
342
|
+
if (positional.length > 1) {
|
|
343
|
+
console.error(`calibrate report takes at most one path\n\n${USAGE}`);
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
let report;
|
|
347
|
+
try {
|
|
348
|
+
report = await calibrationReport(positional[0] ?? ".");
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
console.error(message(err));
|
|
352
|
+
return 2;
|
|
353
|
+
}
|
|
354
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderCalibrateReport(report));
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
async function runRemedyRecord(args) {
|
|
358
|
+
let json = false;
|
|
359
|
+
let classHandle;
|
|
360
|
+
let finding;
|
|
361
|
+
let fix;
|
|
362
|
+
let route;
|
|
363
|
+
let artefact;
|
|
364
|
+
let note;
|
|
365
|
+
const positional = [];
|
|
366
|
+
for (let i = 0; i < args.length; i++) {
|
|
367
|
+
const arg = args[i];
|
|
368
|
+
if (arg === "--json")
|
|
369
|
+
json = true;
|
|
370
|
+
else if (arg === "--class" ||
|
|
371
|
+
arg === "--finding" ||
|
|
372
|
+
arg === "--fix" ||
|
|
373
|
+
arg === "--route" ||
|
|
374
|
+
arg === "--artefact" ||
|
|
375
|
+
arg === "--note") {
|
|
376
|
+
const value = args[++i];
|
|
377
|
+
if (value === undefined) {
|
|
378
|
+
console.error(`${arg} needs a value\n\n${USAGE}`);
|
|
379
|
+
return 2;
|
|
380
|
+
}
|
|
381
|
+
if (arg === "--class")
|
|
382
|
+
classHandle = value;
|
|
383
|
+
else if (arg === "--finding")
|
|
384
|
+
finding = value;
|
|
385
|
+
else if (arg === "--fix")
|
|
386
|
+
fix = value;
|
|
387
|
+
else if (arg === "--route")
|
|
388
|
+
route = value;
|
|
389
|
+
else if (arg === "--artefact")
|
|
390
|
+
artefact = value;
|
|
391
|
+
else
|
|
392
|
+
note = value;
|
|
393
|
+
}
|
|
394
|
+
else if (arg.startsWith("-")) {
|
|
395
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
396
|
+
return 2;
|
|
397
|
+
}
|
|
398
|
+
else
|
|
399
|
+
positional.push(arg);
|
|
400
|
+
}
|
|
401
|
+
if (positional.length > 1) {
|
|
402
|
+
console.error(`remedy record takes at most one path\n\n${USAGE}`);
|
|
403
|
+
return 2;
|
|
404
|
+
}
|
|
405
|
+
if (classHandle === undefined ||
|
|
406
|
+
finding === undefined ||
|
|
407
|
+
fix === undefined ||
|
|
408
|
+
route === undefined) {
|
|
409
|
+
console.error(`remedy record needs --class, --finding, --fix, and --route\n\n${USAGE}`);
|
|
410
|
+
return 2;
|
|
411
|
+
}
|
|
412
|
+
if (!REMEDY_ROUTES.includes(route)) {
|
|
413
|
+
console.error(`--route must be one of ${REMEDY_ROUTES.join(", ")}\n\n${USAGE}`);
|
|
414
|
+
return 2;
|
|
415
|
+
}
|
|
416
|
+
let report;
|
|
417
|
+
try {
|
|
418
|
+
report = await recordRemedy(positional[0] ?? ".", {
|
|
419
|
+
class: classHandle,
|
|
420
|
+
finding,
|
|
421
|
+
fix,
|
|
422
|
+
route: route,
|
|
423
|
+
...(artefact !== undefined && { artefact }),
|
|
424
|
+
...(note !== undefined && { note }),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
console.error(message(err));
|
|
429
|
+
return 2;
|
|
430
|
+
}
|
|
431
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderRemedyRecord(report));
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
async function runRemedyRecall(args) {
|
|
435
|
+
let json = false;
|
|
436
|
+
let classHandle;
|
|
437
|
+
const positional = [];
|
|
438
|
+
for (let i = 0; i < args.length; i++) {
|
|
439
|
+
const arg = args[i];
|
|
440
|
+
if (arg === "--json")
|
|
441
|
+
json = true;
|
|
442
|
+
else if (arg === "--class") {
|
|
443
|
+
const value = args[++i];
|
|
444
|
+
if (value === undefined) {
|
|
445
|
+
console.error(`--class needs a value\n\n${USAGE}`);
|
|
446
|
+
return 2;
|
|
447
|
+
}
|
|
448
|
+
classHandle = value;
|
|
449
|
+
}
|
|
450
|
+
else if (arg.startsWith("-")) {
|
|
451
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
452
|
+
return 2;
|
|
453
|
+
}
|
|
454
|
+
else
|
|
455
|
+
positional.push(arg);
|
|
456
|
+
}
|
|
457
|
+
if (positional.length > 1) {
|
|
458
|
+
console.error(`remedy recall takes at most one path\n\n${USAGE}`);
|
|
459
|
+
return 2;
|
|
460
|
+
}
|
|
461
|
+
if (classHandle === undefined) {
|
|
462
|
+
console.error(`remedy recall needs --class\n\n${USAGE}`);
|
|
463
|
+
return 2;
|
|
464
|
+
}
|
|
465
|
+
let report;
|
|
466
|
+
try {
|
|
467
|
+
report = await recallRemedy(positional[0] ?? ".", classHandle);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
console.error(message(err));
|
|
471
|
+
return 2;
|
|
472
|
+
}
|
|
473
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderRemedyRecall(report));
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
101
476
|
async function runDoctor(args) {
|
|
102
477
|
let json = false;
|
|
103
478
|
const positional = [];
|
|
@@ -266,24 +641,136 @@ async function runInit(args) {
|
|
|
266
641
|
const root = positional[0] ?? ".";
|
|
267
642
|
let config;
|
|
268
643
|
let skills;
|
|
644
|
+
let lenses;
|
|
645
|
+
let doubleLoad;
|
|
269
646
|
try {
|
|
270
|
-
// Materialize first, then stamp the version onto the config — so the recorded
|
|
271
|
-
// only ever
|
|
647
|
+
// Materialize first, then stamp the version onto the config — so the recorded anchors
|
|
648
|
+
// only ever name the skills and lenses that actually landed on disk.
|
|
272
649
|
skills = await materializeSkills(root);
|
|
650
|
+
lenses = await materializeLenses(root);
|
|
273
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);
|
|
274
655
|
}
|
|
275
656
|
catch (err) {
|
|
276
657
|
console.error(message(err));
|
|
277
658
|
return 2;
|
|
278
659
|
}
|
|
279
660
|
if (json) {
|
|
280
|
-
console.log(JSON.stringify({ config, skills }, null, 2));
|
|
661
|
+
console.log(JSON.stringify({ config, skills, lenses, doubleLoad }, null, 2));
|
|
281
662
|
}
|
|
282
663
|
else {
|
|
283
664
|
console.log(renderConfigInit(config));
|
|
284
665
|
console.log("");
|
|
285
666
|
console.log(renderSkillsInit(skills));
|
|
667
|
+
console.log("");
|
|
668
|
+
console.log(renderLensesInit(lenses));
|
|
669
|
+
if (doubleLoad) {
|
|
670
|
+
console.log("");
|
|
671
|
+
console.log(renderDoubleLoad());
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
async function runReviewInit(args) {
|
|
677
|
+
let json = false;
|
|
678
|
+
const positional = [];
|
|
679
|
+
for (const arg of args) {
|
|
680
|
+
if (arg === "--json")
|
|
681
|
+
json = true;
|
|
682
|
+
else if (arg.startsWith("-")) {
|
|
683
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
684
|
+
return 2;
|
|
685
|
+
}
|
|
686
|
+
else
|
|
687
|
+
positional.push(arg);
|
|
688
|
+
}
|
|
689
|
+
if (positional.length > 1) {
|
|
690
|
+
console.error(`review init takes at most one path\n\n${USAGE}`);
|
|
691
|
+
return 2;
|
|
692
|
+
}
|
|
693
|
+
let report;
|
|
694
|
+
try {
|
|
695
|
+
report = await scaffoldReviewWorkflow(positional[0] ?? ".");
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
console.error(message(err));
|
|
699
|
+
return 2;
|
|
700
|
+
}
|
|
701
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderReviewInit(report));
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
704
|
+
async function runReviewRun(args) {
|
|
705
|
+
let json = false;
|
|
706
|
+
let force = false;
|
|
707
|
+
let dryRun = false;
|
|
708
|
+
let pr;
|
|
709
|
+
let repo;
|
|
710
|
+
let base;
|
|
711
|
+
let model;
|
|
712
|
+
const positional = [];
|
|
713
|
+
for (let i = 0; i < args.length; i++) {
|
|
714
|
+
const arg = args[i];
|
|
715
|
+
if (arg === "--json")
|
|
716
|
+
json = true;
|
|
717
|
+
else if (arg === "--force")
|
|
718
|
+
force = true;
|
|
719
|
+
else if (arg === "--dry-run")
|
|
720
|
+
dryRun = true;
|
|
721
|
+
else if (arg === "--pr" || arg === "--repo" || arg === "--base" || arg === "--model") {
|
|
722
|
+
const value = args[++i];
|
|
723
|
+
if (value === undefined) {
|
|
724
|
+
console.error(`${arg} needs a value\n\n${USAGE}`);
|
|
725
|
+
return 2;
|
|
726
|
+
}
|
|
727
|
+
if (arg === "--pr") {
|
|
728
|
+
const number = Number(value);
|
|
729
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
730
|
+
console.error(`--pr needs a pull request number\n\n${USAGE}`);
|
|
731
|
+
return 2;
|
|
732
|
+
}
|
|
733
|
+
pr = number;
|
|
734
|
+
}
|
|
735
|
+
else if (arg === "--repo")
|
|
736
|
+
repo = value;
|
|
737
|
+
else if (arg === "--base")
|
|
738
|
+
base = value;
|
|
739
|
+
else
|
|
740
|
+
model = value;
|
|
741
|
+
}
|
|
742
|
+
else if (arg.startsWith("-")) {
|
|
743
|
+
console.error(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
744
|
+
return 2;
|
|
745
|
+
}
|
|
746
|
+
else
|
|
747
|
+
positional.push(arg);
|
|
748
|
+
}
|
|
749
|
+
if (positional.length > 1) {
|
|
750
|
+
console.error(`review run takes at most one path\n\n${USAGE}`);
|
|
751
|
+
return 2;
|
|
752
|
+
}
|
|
753
|
+
if (pr === undefined) {
|
|
754
|
+
console.error(`review run needs --pr\n\n${USAGE}`);
|
|
755
|
+
return 2;
|
|
756
|
+
}
|
|
757
|
+
let report;
|
|
758
|
+
try {
|
|
759
|
+
report = await reviewRun(positional[0] ?? ".", {
|
|
760
|
+
pr,
|
|
761
|
+
force,
|
|
762
|
+
dryRun,
|
|
763
|
+
...(repo !== undefined && { repo }),
|
|
764
|
+
...(base !== undefined && { base }),
|
|
765
|
+
...(model !== undefined && { model }),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
console.error(message(err));
|
|
770
|
+
return 2;
|
|
286
771
|
}
|
|
772
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderReviewRun(report));
|
|
773
|
+
// Posting findings is not a failure: the risk gate is the check, and it runs as its own step.
|
|
287
774
|
return 0;
|
|
288
775
|
}
|
|
289
776
|
async function runStrengthInit(args) {
|
package/dist/config.js
CHANGED
|
@@ -58,22 +58,25 @@ export async function detectConfig(root) {
|
|
|
58
58
|
* written record is the source of truth, never the detection (ADR-0040), the same posture
|
|
59
59
|
* `strength init` takes with the stack it provisions.
|
|
60
60
|
*
|
|
61
|
-
* When `
|
|
62
|
-
*
|
|
63
|
-
* a re-run would leave
|
|
64
|
-
* themselves stay kept — only the
|
|
61
|
+
* When `payloadVersion` is given, it is (re)stamped onto both the skills and the lenses
|
|
62
|
+
* anchors even on the kept path: `init`/`update` materialize both from the same tarball, so
|
|
63
|
+
* the two anchors must move with them or a re-run would leave a recorded version behind the
|
|
64
|
+
* files it just wrote. The facts themselves stay kept — only the stamps follow the payload.
|
|
65
65
|
*/
|
|
66
|
-
export async function initConfig(root,
|
|
66
|
+
export async function initConfig(root, payloadVersion) {
|
|
67
|
+
const stamped = (config) => payloadVersion === undefined
|
|
68
|
+
? config
|
|
69
|
+
: { ...config, skillsVersion: payloadVersion, lensesVersion: payloadVersion };
|
|
67
70
|
const existing = await readConfig(root);
|
|
68
71
|
if (existing !== undefined) {
|
|
69
|
-
const config =
|
|
70
|
-
|
|
72
|
+
const config = stamped(existing);
|
|
73
|
+
const moved = payloadVersion !== undefined &&
|
|
74
|
+
(existing.skillsVersion !== payloadVersion || existing.lensesVersion !== payloadVersion);
|
|
75
|
+
if (moved)
|
|
71
76
|
await writeConfig(root, config);
|
|
72
|
-
}
|
|
73
77
|
return { root, file: CONFIG_FILE, action: "kept", config };
|
|
74
78
|
}
|
|
75
|
-
const
|
|
76
|
-
const config = skillsVersion !== undefined ? { ...detected, skillsVersion } : detected;
|
|
79
|
+
const config = stamped(await detectConfig(root));
|
|
77
80
|
await writeConfig(root, config);
|
|
78
81
|
return { root, file: CONFIG_FILE, action: "written", config };
|
|
79
82
|
}
|