@redspec/cli 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -0
- package/bin/redspec.js +2 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +1429 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1429 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
// src/commands/accept.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { stamp, writeLock } from "@redspec/core";
|
|
8
|
+
|
|
9
|
+
// src/context.ts
|
|
10
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import {
|
|
13
|
+
loadConfig,
|
|
14
|
+
loadSpecs,
|
|
15
|
+
publishedBoard,
|
|
16
|
+
reportBundle,
|
|
17
|
+
unregisteredBundles
|
|
18
|
+
} from "@redspec/core";
|
|
19
|
+
async function loadContext(root = process.cwd(), now = /* @__PURE__ */ new Date()) {
|
|
20
|
+
const { config, path } = await loadConfig(root);
|
|
21
|
+
const specs = await loadSpecs(root, config);
|
|
22
|
+
const reports = specs.map((s) => reportBundle(root, config, s, now));
|
|
23
|
+
const extra = [...unregisteredBundles(root, config, specs), ...publishedBoard(config)];
|
|
24
|
+
const pkgPath = join(root, "package.json");
|
|
25
|
+
const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")) : null;
|
|
26
|
+
return { root, config, configPath: path, specs, reports, extra, pkg };
|
|
27
|
+
}
|
|
28
|
+
var allFindings = (ctx) => [
|
|
29
|
+
...ctx.reports.flatMap((r) => r.findings),
|
|
30
|
+
...ctx.extra
|
|
31
|
+
];
|
|
32
|
+
function packageManager(root) {
|
|
33
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
34
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
35
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
|
|
36
|
+
return "bun";
|
|
37
|
+
return "npm";
|
|
38
|
+
}
|
|
39
|
+
var runScript = (pm, script) => pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
|
|
40
|
+
|
|
41
|
+
// src/commands/accept.ts
|
|
42
|
+
async function accept(root, opts) {
|
|
43
|
+
const ctx = await loadContext(root);
|
|
44
|
+
const log = opts.quiet ? () => {
|
|
45
|
+
} : console.log;
|
|
46
|
+
const targets = [];
|
|
47
|
+
if (opts.slice) {
|
|
48
|
+
const report2 = ctx.reports.find((r) => r.slices.some((s) => s.path === opts.slice));
|
|
49
|
+
const slice2 = report2?.slices.find((s) => s.path === opts.slice);
|
|
50
|
+
if (!report2 || !slice2) {
|
|
51
|
+
console.error(pc.red(`No slice at ${opts.slice}.`));
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
for (const id of slice2.claims) targets.push({ id, slice: slice2.path, report: report2 });
|
|
55
|
+
}
|
|
56
|
+
for (const id of opts.ids ?? []) {
|
|
57
|
+
const report2 = ctx.reports.find((r) => id in r.digests);
|
|
58
|
+
if (!report2) {
|
|
59
|
+
console.error(
|
|
60
|
+
pc.red(`Nothing digests to "${id}". Is it declared, and spelled as declared?`)
|
|
61
|
+
);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const owners = report2.slices.filter((s) => s.claims.includes(id));
|
|
65
|
+
const owner = owners[owners.length - 1];
|
|
66
|
+
if (!owner) {
|
|
67
|
+
console.error(
|
|
68
|
+
pc.red(
|
|
69
|
+
`"${id}" is claimed by no slice. Claim it first (\`redspec new slice\`), or accept via --slice.`
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
targets.push({ id, slice: owner.path, report: report2 });
|
|
75
|
+
}
|
|
76
|
+
if (targets.length === 0) {
|
|
77
|
+
console.error(pc.red("Nothing to accept. Pass IDs or --slice <path>."));
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const command = opts.command ?? ctx.config.accept.command;
|
|
81
|
+
log(pc.dim(`$ ${command}`));
|
|
82
|
+
const run2 = spawnSync(command, {
|
|
83
|
+
shell: true,
|
|
84
|
+
stdio: opts.quiet ? "ignore" : "inherit",
|
|
85
|
+
cwd: root
|
|
86
|
+
});
|
|
87
|
+
if (run2.status !== 0) {
|
|
88
|
+
console.error(pc.red(`
|
|
89
|
+
Verification exited ${run2.status}. Nothing stamped.`));
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
|
|
93
|
+
cwd: root,
|
|
94
|
+
encoding: "utf8"
|
|
95
|
+
});
|
|
96
|
+
const sha = commit.status === 0 ? commit.stdout.trim() : void 0;
|
|
97
|
+
const byReport = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const tg of targets)
|
|
99
|
+
byReport.set(tg.report.slug, [...byReport.get(tg.report.slug) ?? [], tg]);
|
|
100
|
+
for (const [slug, tgs] of byReport) {
|
|
101
|
+
const report2 = ctx.reports.find((r) => r.slug === slug);
|
|
102
|
+
let lock = report2.lock;
|
|
103
|
+
for (const tg of tgs) {
|
|
104
|
+
lock = stamp(lock, [tg.id], report2.digests, tg.slice, {
|
|
105
|
+
commit: sha,
|
|
106
|
+
note: opts.clarification
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
writeLock(report2.lockPath, lock);
|
|
110
|
+
for (const tg of tgs)
|
|
111
|
+
log(` ${pc.green("stamped")} ${tg.id} ${pc.dim(`\u2190 ${tg.slice}`)}`);
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/commands/board.ts
|
|
117
|
+
import { spawn } from "child_process";
|
|
118
|
+
import pc2 from "picocolors";
|
|
119
|
+
async function board(root, feature) {
|
|
120
|
+
const ctx = await loadContext(root);
|
|
121
|
+
if (ctx.config.framework === "none") {
|
|
122
|
+
console.error(
|
|
123
|
+
pc2.red(
|
|
124
|
+
'No framework adapter configured, so there is no spec route to open. Set framework: "next" in spec.config.ts.'
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
const url = `http://localhost:3000${ctx.config.route}${feature ? `/${feature}` : ""}`;
|
|
130
|
+
console.log(pc2.dim(`$ ${runScript(packageManager(root), "dev")}`));
|
|
131
|
+
console.log(`${pc2.bold("Board:")} ${url}`);
|
|
132
|
+
const child = spawn(runScript(packageManager(root), "dev"), {
|
|
133
|
+
shell: true,
|
|
134
|
+
stdio: "inherit",
|
|
135
|
+
cwd: root
|
|
136
|
+
});
|
|
137
|
+
return await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 0)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/commands/check.ts
|
|
141
|
+
import pc4 from "picocolors";
|
|
142
|
+
|
|
143
|
+
// src/print.ts
|
|
144
|
+
import pc3 from "picocolors";
|
|
145
|
+
import { flowCoverage } from "@redspec/core";
|
|
146
|
+
var GROUPS = [
|
|
147
|
+
{
|
|
148
|
+
title: "UNNAMED \u2014 declared with only an ID",
|
|
149
|
+
kinds: ["unnamed-state"],
|
|
150
|
+
hint: "\u2192 say what the person is looking at",
|
|
151
|
+
tone: pc3.yellow
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
title: "DECLARED \u2014 not yet rendered",
|
|
155
|
+
kinds: ["declared-not-rendered"],
|
|
156
|
+
hint: "\u2192 /render-states",
|
|
157
|
+
tone: pc3.yellow
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
title: "AMENDED \u2014 changed since it was verified",
|
|
161
|
+
kinds: ["amended", "unverified"],
|
|
162
|
+
hint: "\u2192 /amend, or redspec accept",
|
|
163
|
+
tone: pc3.magenta
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
title: "WAIVERS",
|
|
167
|
+
kinds: ["waiver-due", "waiver-unwitnessed", "unknown-witness"],
|
|
168
|
+
hint: "\u2192 re-read it: believe it, or build the state",
|
|
169
|
+
tone: pc3.cyan
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
title: "DECISION TABLES",
|
|
173
|
+
kinds: ["table-gap", "table-overlap", "table-parse", "unknown-state-outcome"],
|
|
174
|
+
hint: "\u2192 make the table total",
|
|
175
|
+
tone: pc3.red
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
title: "SHAPE",
|
|
179
|
+
kinds: [
|
|
180
|
+
"off-path",
|
|
181
|
+
"off-checklist",
|
|
182
|
+
"unclaimed-id",
|
|
183
|
+
"unknown-surface",
|
|
184
|
+
"surface-mismatch",
|
|
185
|
+
"dangling-deviation",
|
|
186
|
+
"deviation-off-deviation",
|
|
187
|
+
"compound-actor",
|
|
188
|
+
"spine-ends-early",
|
|
189
|
+
"empty-spine",
|
|
190
|
+
"bad-id"
|
|
191
|
+
],
|
|
192
|
+
hint: "\u2192 the registry disagrees with itself",
|
|
193
|
+
tone: pc3.red
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
title: "BUNDLE",
|
|
197
|
+
kinds: [
|
|
198
|
+
"actor-without-flow",
|
|
199
|
+
"missing-brief",
|
|
200
|
+
"unregistered-feature",
|
|
201
|
+
"unknown-copy"
|
|
202
|
+
],
|
|
203
|
+
hint: "\u2192 the bundle on disk disagrees with the registry",
|
|
204
|
+
tone: pc3.red
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
title: "CONFIG",
|
|
208
|
+
kinds: ["board-published"],
|
|
209
|
+
hint: "\u2192 the repo disagrees with its own declarations",
|
|
210
|
+
tone: pc3.red
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
title: "COVERAGE",
|
|
214
|
+
kinds: ["orphan", "claimless", "unknown-id", "claimed-twice"],
|
|
215
|
+
hint: "\u2192 /cut-slices",
|
|
216
|
+
tone: pc3.red
|
|
217
|
+
}
|
|
218
|
+
];
|
|
219
|
+
function printStatus(ctx, write = console.log) {
|
|
220
|
+
if (ctx.specs.length === 0 && ctx.extra.length === 0) {
|
|
221
|
+
write(pc3.dim("No specs yet. Run /draft-skeleton, or `redspec new feature <slug>`."));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const report2 of ctx.reports) {
|
|
225
|
+
const spec = ctx.specs.find((s) => s.spec.slug === report2.slug).spec;
|
|
226
|
+
const declared = report2.artifacts.filter((a) => a.kind === "STATE").length;
|
|
227
|
+
const rendered = Object.keys(spec.cases).length;
|
|
228
|
+
write(
|
|
229
|
+
`${pc3.bold(report2.slug)}${" ".repeat(Math.max(1, 50 - report2.slug.length))}${pc3.dim(`${rendered} of ${declared} states rendered`)}`
|
|
230
|
+
);
|
|
231
|
+
printGroups(report2.findings, write);
|
|
232
|
+
const cov = flowCoverage(spec, ctx.config.journeyBudget);
|
|
233
|
+
const paths = cov.reduce((n, c) => n + c.reachablePaths, 0);
|
|
234
|
+
const stamped = Object.keys(report2.lock.entries).length;
|
|
235
|
+
const line = [
|
|
236
|
+
`${report2.findings.length} finding${report2.findings.length === 1 ? "" : "s"}`,
|
|
237
|
+
`${cov.length} flow${cov.length === 1 ? "" : "s"} \xB7 ${paths} reachable path${paths === 1 ? "" : "s"}${cov.some((c) => c.truncated) ? " (truncated)" : ""}`,
|
|
238
|
+
`${stamped} of ${report2.artifacts.length} artifacts stamped`
|
|
239
|
+
].join(" \xB7 ");
|
|
240
|
+
write(pc3.dim(` ${line}`));
|
|
241
|
+
write("");
|
|
242
|
+
}
|
|
243
|
+
if (ctx.extra.length) {
|
|
244
|
+
printGroups(ctx.extra, write);
|
|
245
|
+
write("");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function printGroups(findings, write) {
|
|
249
|
+
for (const group of GROUPS) {
|
|
250
|
+
const rows = findings.filter((f) => group.kinds.includes(f.kind));
|
|
251
|
+
if (rows.length === 0) continue;
|
|
252
|
+
write("");
|
|
253
|
+
write(
|
|
254
|
+
` ${group.tone(pc3.bold(group.title))}${" ".repeat(Math.max(1, 48 - group.title.length))}${pc3.dim(group.hint)}`
|
|
255
|
+
);
|
|
256
|
+
for (const f of rows) {
|
|
257
|
+
write(` ${f.id}${f.at ? pc3.dim(` (${f.at})`) : ""}`);
|
|
258
|
+
write(pc3.dim(` ${f.kind}: ${f.detail}`));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function printCheck(findings, write = console.log) {
|
|
263
|
+
for (const f of findings) {
|
|
264
|
+
write(`${pc3.red(f.kind.padEnd(24))} ${f.id}${f.at ? pc3.dim(` ${f.at}`) : ""}`);
|
|
265
|
+
write(pc3.dim(`${" ".repeat(25)}${f.detail}`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/commands/check.ts
|
|
270
|
+
async function check(root, opts = {}) {
|
|
271
|
+
const ctx = await loadContext(root);
|
|
272
|
+
const findings = allFindings(ctx);
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
console.log(
|
|
275
|
+
JSON.stringify({ findings, specs: ctx.specs.map((s) => s.spec.slug) }, null, 2)
|
|
276
|
+
);
|
|
277
|
+
return findings.length ? 1 : 0;
|
|
278
|
+
}
|
|
279
|
+
if (findings.length === 0) {
|
|
280
|
+
if (!opts.quiet)
|
|
281
|
+
console.log(
|
|
282
|
+
pc4.green(
|
|
283
|
+
`redspec: clean. ${ctx.specs.length} spec${ctx.specs.length === 1 ? "" : "s"}, ${ctx.reports.reduce((n, r) => n + r.artifacts.length, 0)} artifacts.`
|
|
284
|
+
)
|
|
285
|
+
);
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
if (!opts.quiet) printCheck(findings);
|
|
289
|
+
console.log("");
|
|
290
|
+
console.log(
|
|
291
|
+
pc4.red(`${findings.length} finding${findings.length === 1 ? "" : "s"}. Exit 1.`) + pc4.dim(" redspec status for the grouped view.")
|
|
292
|
+
);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/commands/doctor.ts
|
|
297
|
+
import { existsSync as existsSync6 } from "fs";
|
|
298
|
+
import { join as join6 } from "path";
|
|
299
|
+
import pc6 from "picocolors";
|
|
300
|
+
import { DIGEST_ALGO, HARNESSES } from "@redspec/core";
|
|
301
|
+
import { CAPABILITIES } from "@redspec/method";
|
|
302
|
+
|
|
303
|
+
// src/harness.ts
|
|
304
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
305
|
+
import { join as join2 } from "path";
|
|
306
|
+
function detectHarnesses(root, env = process.env) {
|
|
307
|
+
const found = [];
|
|
308
|
+
const has = (p2) => existsSync2(join2(root, p2));
|
|
309
|
+
if (has(".claude") || has("CLAUDE.md") || env.CLAUDECODE)
|
|
310
|
+
found.push({
|
|
311
|
+
harness: "claude",
|
|
312
|
+
evidence: has(".claude") ? ".claude/" : has("CLAUDE.md") ? "CLAUDE.md" : "$CLAUDECODE"
|
|
313
|
+
});
|
|
314
|
+
if (has(".cursor") || has(".cursorrules") || env.CURSOR_TRACE_ID)
|
|
315
|
+
found.push({
|
|
316
|
+
harness: "cursor",
|
|
317
|
+
evidence: has(".cursor") ? ".cursor/" : ".cursorrules"
|
|
318
|
+
});
|
|
319
|
+
const agentsMd = has("AGENTS.md") ? readFileSync2(join2(root, "AGENTS.md"), "utf8") : null;
|
|
320
|
+
const foreignAgentsMd = agentsMd !== null && agentsMd.replace(/<!-- redspec:start -->[\s\S]*?<!-- redspec:end -->/g, "").trim().length > 0;
|
|
321
|
+
if (has(".codex") || foreignAgentsMd)
|
|
322
|
+
found.push({ harness: "codex", evidence: has(".codex") ? ".codex/" : "AGENTS.md" });
|
|
323
|
+
if (has(".github/copilot-instructions.md") || has(".github/prompts"))
|
|
324
|
+
found.push({ harness: "copilot", evidence: ".github/copilot-instructions.md" });
|
|
325
|
+
if (has(".gemini") || has("GEMINI.md"))
|
|
326
|
+
found.push({ harness: "gemini", evidence: has(".gemini") ? ".gemini/" : "GEMINI.md" });
|
|
327
|
+
return found;
|
|
328
|
+
}
|
|
329
|
+
function detectFramework(pkg) {
|
|
330
|
+
const deps = {
|
|
331
|
+
...pkg?.dependencies ?? {},
|
|
332
|
+
...pkg?.devDependencies ?? {}
|
|
333
|
+
};
|
|
334
|
+
if (deps.next) return "next";
|
|
335
|
+
return "none";
|
|
336
|
+
}
|
|
337
|
+
function hasTestRunner(pkg) {
|
|
338
|
+
const deps = {
|
|
339
|
+
...pkg?.dependencies ?? {},
|
|
340
|
+
...pkg?.devDependencies ?? {}
|
|
341
|
+
};
|
|
342
|
+
return {
|
|
343
|
+
unit: deps.vitest ? "vitest" : deps.jest ? "jest" : null,
|
|
344
|
+
browser: deps["@playwright/test"] ? "playwright" : null
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/install.ts
|
|
349
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
350
|
+
import { existsSync as existsSync3 } from "fs";
|
|
351
|
+
import { join as join3 } from "path";
|
|
352
|
+
import pc5 from "picocolors";
|
|
353
|
+
function missingDeps(root, deps) {
|
|
354
|
+
return deps.filter((d) => !existsSync3(join3(root, "node_modules", d)));
|
|
355
|
+
}
|
|
356
|
+
function installArgs(pm, deps, dev) {
|
|
357
|
+
if (pm === "npm")
|
|
358
|
+
return ["npm", "install", "--save-exact", dev ? "--save-dev" : "--save", ...deps];
|
|
359
|
+
if (pm === "yarn") return ["yarn", "add", "--exact", ...dev ? ["--dev"] : [], ...deps];
|
|
360
|
+
if (pm === "bun") return ["bun", "add", "--exact", ...dev ? ["--dev"] : [], ...deps];
|
|
361
|
+
return ["pnpm", "add", "--save-exact", dev ? "--save-dev" : "--save-prod", ...deps];
|
|
362
|
+
}
|
|
363
|
+
var installCommand = (pm, deps, dev) => installArgs(pm, deps, dev).join(" ");
|
|
364
|
+
function installDeps(root, pm, deps, dev, log) {
|
|
365
|
+
if (!deps.length) return true;
|
|
366
|
+
log("");
|
|
367
|
+
log(`Installing ${dev ? "devDependencies" : "dependencies"} (${pm}):`);
|
|
368
|
+
for (const d of deps) log(` ${pc5.cyan(d)}`);
|
|
369
|
+
const [cmd, ...args] = installArgs(pm, deps, dev);
|
|
370
|
+
const run2 = spawnSync2(cmd, args, { cwd: root, stdio: "inherit" });
|
|
371
|
+
return run2.status === 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/commands/sync.ts
|
|
375
|
+
import { createHash } from "crypto";
|
|
376
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
377
|
+
import { join as join5 } from "path";
|
|
378
|
+
import {
|
|
379
|
+
readMethod,
|
|
380
|
+
renderHarness
|
|
381
|
+
} from "@redspec/method";
|
|
382
|
+
|
|
383
|
+
// src/fs.ts
|
|
384
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
385
|
+
import { dirname, join as join4 } from "path";
|
|
386
|
+
import { mergeSection } from "@redspec/method";
|
|
387
|
+
var Writer = class {
|
|
388
|
+
constructor(root) {
|
|
389
|
+
this.root = root;
|
|
390
|
+
}
|
|
391
|
+
root;
|
|
392
|
+
written = [];
|
|
393
|
+
/** Write only if the file does not exist. */
|
|
394
|
+
create(rel, content) {
|
|
395
|
+
const abs = join4(this.root, rel);
|
|
396
|
+
if (existsSync4(abs)) {
|
|
397
|
+
this.written.push({ path: rel, action: "kept" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
401
|
+
writeFileSync(abs, content);
|
|
402
|
+
this.written.push({ path: rel, action: "created" });
|
|
403
|
+
}
|
|
404
|
+
/** Overwrite; these are generated files that carry a "generated" header. */
|
|
405
|
+
write(rel, content) {
|
|
406
|
+
const abs = join4(this.root, rel);
|
|
407
|
+
const existed = existsSync4(abs);
|
|
408
|
+
if (existed && readFileSync3(abs, "utf8") === content) {
|
|
409
|
+
this.written.push({ path: rel, action: "kept" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
413
|
+
writeFileSync(abs, content);
|
|
414
|
+
this.written.push({ path: rel, action: existed ? "updated" : "created" });
|
|
415
|
+
}
|
|
416
|
+
/** Replace or append a marked section, leaving the rest of the file alone. */
|
|
417
|
+
section(rel, content) {
|
|
418
|
+
const abs = join4(this.root, rel);
|
|
419
|
+
const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : null;
|
|
420
|
+
const next = mergeSection(existing, content);
|
|
421
|
+
if (existing === next) {
|
|
422
|
+
this.written.push({ path: rel, action: "kept" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
426
|
+
writeFileSync(abs, next);
|
|
427
|
+
this.written.push({ path: rel, action: existing ? "updated" : "created" });
|
|
428
|
+
}
|
|
429
|
+
append(rel, content) {
|
|
430
|
+
const abs = join4(this.root, rel);
|
|
431
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
432
|
+
writeFileSync(abs, (existsSync4(abs) ? readFileSync3(abs, "utf8") : "") + content);
|
|
433
|
+
this.written.push({ path: rel, action: "updated" });
|
|
434
|
+
}
|
|
435
|
+
read(rel) {
|
|
436
|
+
const abs = join4(this.root, rel);
|
|
437
|
+
return existsSync4(abs) ? readFileSync3(abs, "utf8") : null;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// src/commands/sync.ts
|
|
442
|
+
var CONTEXT_LOCK = ".redspec/contexts.json";
|
|
443
|
+
var digest = (s) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
444
|
+
function renderContext(root, config) {
|
|
445
|
+
const pm = packageManager(root);
|
|
446
|
+
return {
|
|
447
|
+
specsDir: config.specsDir,
|
|
448
|
+
route: config.route,
|
|
449
|
+
framework: config.framework,
|
|
450
|
+
unitCommand: runScript(pm, "test"),
|
|
451
|
+
stateCommand: runScript(pm, "test:state"),
|
|
452
|
+
journeyCommand: runScript(pm, "test:journey"),
|
|
453
|
+
conventionsPath: "docs/agents/redspec.md",
|
|
454
|
+
publicBoard: config.publicBoard
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function sync(root, config, harnesses = config.harnesses) {
|
|
458
|
+
const method = readMethod();
|
|
459
|
+
const ctx = renderContext(root, config);
|
|
460
|
+
const w = new Writer(root);
|
|
461
|
+
const lock = { version: 1, files: {} };
|
|
462
|
+
const files = /* @__PURE__ */ new Map();
|
|
463
|
+
for (const h of harnesses) {
|
|
464
|
+
for (const f of renderHarness(h, method, ctx)) {
|
|
465
|
+
const prev = files.get(f.path);
|
|
466
|
+
if (!prev || f.content.length > prev.content.length) files.set(f.path, f);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const f of files.values()) {
|
|
470
|
+
if (f.mode === "section") w.section(f.path, f.content);
|
|
471
|
+
else w.write(f.path, f.content);
|
|
472
|
+
lock.files[f.path] = digest(f.content);
|
|
473
|
+
}
|
|
474
|
+
w.write(CONTEXT_LOCK, JSON.stringify(lock, null, 2) + "\n");
|
|
475
|
+
return w;
|
|
476
|
+
}
|
|
477
|
+
function staleContexts(root, config) {
|
|
478
|
+
const lockPath = join5(root, CONTEXT_LOCK);
|
|
479
|
+
if (!existsSync5(lockPath)) return [];
|
|
480
|
+
const lock = JSON.parse(readFileSync4(lockPath, "utf8"));
|
|
481
|
+
const method = readMethod();
|
|
482
|
+
const ctx = renderContext(root, config);
|
|
483
|
+
const files = /* @__PURE__ */ new Map();
|
|
484
|
+
for (const h of config.harnesses) {
|
|
485
|
+
for (const f of renderHarness(h, method, ctx)) {
|
|
486
|
+
const prev = files.get(f.path);
|
|
487
|
+
if (!prev || f.content.length > prev.content.length) files.set(f.path, f);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return [...files.values()].filter((f) => lock.files[f.path] !== digest(f.content)).map((f) => f.path);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/commands/doctor.ts
|
|
494
|
+
async function doctor(root) {
|
|
495
|
+
let problems = 0;
|
|
496
|
+
const ok = (s) => console.log(` ${pc6.green("\u2713")} ${s}`);
|
|
497
|
+
const warn = (s) => console.log(` ${pc6.yellow("!")} ${s}`);
|
|
498
|
+
const bad = (s) => {
|
|
499
|
+
problems++;
|
|
500
|
+
console.log(` ${pc6.red("\u2717")} ${s}`);
|
|
501
|
+
};
|
|
502
|
+
let ctx;
|
|
503
|
+
try {
|
|
504
|
+
ctx = await loadContext(root);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
bad(`Specs failed to load: ${e.message}`);
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
509
|
+
console.log(pc6.bold("Config"));
|
|
510
|
+
ctx.configPath ? ok(
|
|
511
|
+
`spec.config.ts (framework: ${ctx.config.framework}, route: ${ctx.config.route})`
|
|
512
|
+
) : bad("No spec.config.ts. Run `redspec init`.");
|
|
513
|
+
ok(
|
|
514
|
+
`${ctx.specs.length} feature${ctx.specs.length === 1 ? "" : "s"} load${ctx.specs.length === 1 ? "s" : ""}`
|
|
515
|
+
);
|
|
516
|
+
for (const r of ctx.reports) {
|
|
517
|
+
if (r.lock.algo !== 0 && r.lock.algo !== DIGEST_ALGO)
|
|
518
|
+
bad(
|
|
519
|
+
`${r.slug}: lock algo ${r.lock.algo}, this redspec writes ${DIGEST_ALGO}. Run \`redspec accept\` over its claims after upgrading.`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
for (const f of ctx.extra) warn(`${f.id}: ${f.detail}`);
|
|
523
|
+
if (ctx.config.framework === "next") {
|
|
524
|
+
console.log(pc6.bold("\nNext.js"));
|
|
525
|
+
const src = existsSync6(join6(root, "src/app")) ? "src/" : "";
|
|
526
|
+
const has = (p2) => existsSync6(join6(root, src + p2));
|
|
527
|
+
has("proxy.ts") || has("proxy.redspec.ts") ? ok("proxy.ts gates the spec route in production") : bad("No proxy.ts. The spec route will serve in production.");
|
|
528
|
+
has("app/spec/_routes.ts") ? ok("app/spec/ routes present") : bad("app/spec/_routes.ts missing.");
|
|
529
|
+
existsSync6(join6(root, ctx.config.specsDir, "index.ts")) ? ok(`${ctx.config.specsDir}/index.ts registers features for the app`) : bad(`${ctx.config.specsDir}/index.ts missing.`);
|
|
530
|
+
}
|
|
531
|
+
console.log(pc6.bold("\nDependencies"));
|
|
532
|
+
const pm = packageManager(root);
|
|
533
|
+
const runners = hasTestRunner(ctx.pkg);
|
|
534
|
+
const runtime = ["@redspec/core", "@redspec/cli"];
|
|
535
|
+
if (ctx.config.framework === "next") runtime.push("@redspec/next");
|
|
536
|
+
const dev = [
|
|
537
|
+
"fast-check",
|
|
538
|
+
runners.unit === "jest" ? "jest" : "vitest",
|
|
539
|
+
"@playwright/test"
|
|
540
|
+
];
|
|
541
|
+
const needRuntime = missingDeps(root, runtime);
|
|
542
|
+
const needDev = missingDeps(root, dev);
|
|
543
|
+
const missing = /* @__PURE__ */ new Set([...needRuntime, ...needDev]);
|
|
544
|
+
for (const d of [...runtime, ...dev]) {
|
|
545
|
+
missing.has(d) ? bad(`${d} is not installed.`) : ok(d);
|
|
546
|
+
}
|
|
547
|
+
if (needRuntime.length)
|
|
548
|
+
console.log(pc6.dim(` ${installCommand(pm, needRuntime, false)}`));
|
|
549
|
+
if (needDev.length) console.log(pc6.dim(` ${installCommand(pm, needDev, true)}`));
|
|
550
|
+
console.log(pc6.bold("\nHarnesses"));
|
|
551
|
+
const detected = new Set(detectHarnesses(root).map((d) => d.harness));
|
|
552
|
+
for (const h of HARNESSES) {
|
|
553
|
+
const configured = ctx.config.harnesses.includes(h);
|
|
554
|
+
const cap = CAPABILITIES[h];
|
|
555
|
+
const mark = configured ? pc6.green("\u2713") : detected.has(h) ? pc6.yellow("!") : pc6.dim("\u25CB");
|
|
556
|
+
const label = configured ? "configured" : detected.has(h) ? "detected, not configured \u2014 add to spec.config.ts harnesses and `redspec sync`" : "";
|
|
557
|
+
console.log(` ${mark} ${h.padEnd(9)} ${pc6.dim(label)}`);
|
|
558
|
+
if (configured) {
|
|
559
|
+
console.log(
|
|
560
|
+
` ${cap.hitlOnly ? pc6.green("steps are HITL-only") : pc6.yellow("steps are conventions, not gates")} \xB7 ${cap.subagents ? "adversary/verifier run as subagents" : "adversary/verifier run as fresh tasks"}`
|
|
561
|
+
);
|
|
562
|
+
console.log(pc6.dim(` ${cap.note}`));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const stale = staleContexts(root, ctx.config);
|
|
566
|
+
if (stale.length) {
|
|
567
|
+
warn(
|
|
568
|
+
`${stale.length} rendered context file${stale.length === 1 ? " is" : "s are"} stale (redspec upgraded, or config changed). Run \`redspec sync\`.`
|
|
569
|
+
);
|
|
570
|
+
for (const s of stale) console.log(pc6.dim(` ${s}`));
|
|
571
|
+
} else if (ctx.config.harnesses.length)
|
|
572
|
+
ok("rendered contexts match this redspec version");
|
|
573
|
+
console.log("");
|
|
574
|
+
console.log(
|
|
575
|
+
problems ? pc6.red(`${problems} problem${problems === 1 ? "" : "s"}.`) : pc6.green("Healthy.")
|
|
576
|
+
);
|
|
577
|
+
return problems ? 1 : 0;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/commands/init.ts
|
|
581
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
582
|
+
import { join as join7 } from "path";
|
|
583
|
+
import * as p from "@clack/prompts";
|
|
584
|
+
import pc7 from "picocolors";
|
|
585
|
+
import {
|
|
586
|
+
defineSpecConfig,
|
|
587
|
+
HARNESSES as HARNESSES2,
|
|
588
|
+
loadConfig as loadConfig2
|
|
589
|
+
} from "@redspec/core";
|
|
590
|
+
import { CAPABILITIES as CAPABILITIES2 } from "@redspec/method";
|
|
591
|
+
|
|
592
|
+
// src/templates.ts
|
|
593
|
+
var specConfig = (framework, harnesses) => `import { defineSpecConfig } from "@redspec/core"
|
|
594
|
+
|
|
595
|
+
export default defineSpecConfig({
|
|
596
|
+
framework: "${framework}",
|
|
597
|
+
route: "/spec",
|
|
598
|
+
caseViewport: { width: 1280, height: 720 },
|
|
599
|
+
// "witnessed" makes every waiver name the INV- that would go red if it stopped holding.
|
|
600
|
+
waivers: "free",
|
|
601
|
+
// Must exit 0 in the same invocation for \`redspec accept\` to stamp anything.
|
|
602
|
+
accept: { command: "pnpm test && pnpm test:state" },
|
|
603
|
+
// Which agent harnesses \`redspec sync\` writes context for.
|
|
604
|
+
harnesses: ${JSON.stringify(harnesses)},
|
|
605
|
+
})
|
|
606
|
+
`;
|
|
607
|
+
var proxy = `import { createSpecProxy } from "@redspec/next/gate"
|
|
608
|
+
|
|
609
|
+
// The production gate. It answers 404 before anything renders: a layout-level
|
|
610
|
+
// notFound() still serializes the page into the response body.
|
|
611
|
+
export const proxy = createSpecProxy({ route: "/spec" })
|
|
612
|
+
|
|
613
|
+
// Next statically parses this at build time, so every value has to stay a
|
|
614
|
+
// literal -- a helper call here fails the build.
|
|
615
|
+
export const config = { matcher: ["/spec", "/spec/:path*"] }
|
|
616
|
+
`;
|
|
617
|
+
var specsIndex = `// Every feature this repo declares. \`redspec new feature\` appends here.
|
|
618
|
+
import type { Spec } from "@redspec/core"
|
|
619
|
+
|
|
620
|
+
// redspec:imports
|
|
621
|
+
|
|
622
|
+
export const specs: Spec[] = [
|
|
623
|
+
// redspec:specs
|
|
624
|
+
]
|
|
625
|
+
`;
|
|
626
|
+
var nextRoutes = (config) => `import { createSpecRoutes } from "@redspec/next"
|
|
627
|
+
import { specs } from "../../specs"
|
|
628
|
+
|
|
629
|
+
export const { SpecLayout, SpecIndexPage, SpecBoardPage, SpecCasePage, generateStaticParams } =
|
|
630
|
+
createSpecRoutes(specs, {
|
|
631
|
+
route: ${JSON.stringify(config.route)},
|
|
632
|
+
specsDir: ${JSON.stringify(config.specsDir)},
|
|
633
|
+
stateTestsDir: ${JSON.stringify(config.stateTestsDir)},
|
|
634
|
+
})
|
|
635
|
+
`;
|
|
636
|
+
var nextLayout = `export { SpecLayout as default } from "./_routes"
|
|
637
|
+
`;
|
|
638
|
+
var nextIndex = `export { SpecIndexPage as default } from "./_routes"
|
|
639
|
+
`;
|
|
640
|
+
var nextBoard = `export { SpecBoardPage as default, generateStaticParams } from "../_routes"
|
|
641
|
+
`;
|
|
642
|
+
var nextCase = `export { SpecCasePage as default } from "../../_routes"
|
|
643
|
+
`;
|
|
644
|
+
var playwrightConfig = `import { defineConfig, devices } from "@playwright/test"
|
|
645
|
+
|
|
646
|
+
// Two tiers, differing by target rather than by tool. \`state\` points at the
|
|
647
|
+
// spec route -- fixtures only, no auth, no backend -- and asserts exhaustively.
|
|
648
|
+
// \`journey\` points at the real app and stays a handful of paths. Both run
|
|
649
|
+
// against the dev server: the spec route 404s in production by design.
|
|
650
|
+
export default defineConfig({
|
|
651
|
+
testDir: "./e2e",
|
|
652
|
+
fullyParallel: true,
|
|
653
|
+
forbidOnly: !!process.env.CI,
|
|
654
|
+
retries: process.env.CI ? 2 : 0,
|
|
655
|
+
reporter: process.env.CI ? "github" : "list",
|
|
656
|
+
expect: { toHaveScreenshot: { stylePath: "./e2e/screenshot.css" } },
|
|
657
|
+
use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
|
|
658
|
+
projects: [
|
|
659
|
+
{ name: "state", testDir: "./e2e/state", use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 } } },
|
|
660
|
+
{ name: "journey", testDir: "./e2e/journey", use: { ...devices["Desktop Chrome"] } },
|
|
661
|
+
],
|
|
662
|
+
webServer: { command: "pnpm dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120_000 },
|
|
663
|
+
})
|
|
664
|
+
`;
|
|
665
|
+
var screenshotCss = `/* Injected into every screenshot. The Next dev-tools indicator changes with the
|
|
666
|
+
* dev server's state, so it stays out of frame. */
|
|
667
|
+
nextjs-portal { display: none !important; }
|
|
668
|
+
`;
|
|
669
|
+
var ciWorkflow = `name: redspec
|
|
670
|
+
on:
|
|
671
|
+
pull_request:
|
|
672
|
+
push:
|
|
673
|
+
branches: [main]
|
|
674
|
+
jobs:
|
|
675
|
+
spec:
|
|
676
|
+
runs-on: ubuntu-latest
|
|
677
|
+
steps:
|
|
678
|
+
- uses: actions/checkout@v4
|
|
679
|
+
- uses: pnpm/action-setup@v4
|
|
680
|
+
- uses: actions/setup-node@v4
|
|
681
|
+
with: { node-version: 22, cache: pnpm }
|
|
682
|
+
- run: pnpm install --frozen-lockfile
|
|
683
|
+
# The gate. Red on a skeleton is correct; red on main is a stop.
|
|
684
|
+
- run: pnpm exec redspec check
|
|
685
|
+
- run: pnpm test
|
|
686
|
+
`;
|
|
687
|
+
var brief = (slug, title2) => `# ${title2}
|
|
688
|
+
|
|
689
|
+
## Problem
|
|
690
|
+
|
|
691
|
+
What is wrong today, from the perspective of the person it is wrong for. Two or three sentences.
|
|
692
|
+
|
|
693
|
+
## Actors
|
|
694
|
+
|
|
695
|
+
- **Someone**: what they want from this. One bolded bullet per actor; the audit reads this list and fails on an actor with no flow.
|
|
696
|
+
|
|
697
|
+
## What changes
|
|
698
|
+
|
|
699
|
+
The shortest honest statement of the new capability.
|
|
700
|
+
|
|
701
|
+
## Non-goals
|
|
702
|
+
|
|
703
|
+
- **Something a reader would assume is included.** Why it is not.
|
|
704
|
+
|
|
705
|
+
## Deliberate unknowns
|
|
706
|
+
|
|
707
|
+
- **A question knowingly left open.** What happens if the guess is wrong, and how expensive that is.
|
|
708
|
+
`;
|
|
709
|
+
var specTs = (slug, title2) => `import { defineSpec } from "@redspec/core"
|
|
710
|
+
import { copy } from "./copy"
|
|
711
|
+
import * as fixtures from "./fixtures"
|
|
712
|
+
import * as sketches from "./sketches"
|
|
713
|
+
|
|
714
|
+
// \`redspec status\` is the work list. After /draft-skeleton, \`surfaces\` and
|
|
715
|
+
// \`flows\` are filled and \`cases\` is empty: every declared state is a stub on
|
|
716
|
+
// the board and a red line in status. /render-states fills \`cases\`.
|
|
717
|
+
export default defineSpec({
|
|
718
|
+
slug: "${slug}",
|
|
719
|
+
title: "${title2}",
|
|
720
|
+
|
|
721
|
+
surfaces: {
|
|
722
|
+
// <key>: {
|
|
723
|
+
// title: "The screen",
|
|
724
|
+
// checklist: {
|
|
725
|
+
// empty: { state: "STATE-${slug}-<key>-empty" },
|
|
726
|
+
// loading: { state: "STATE-${slug}-<key>-loading" },
|
|
727
|
+
// partial: { waived: "Why this screen cannot be half-loaded.", witness: "INV-\u2026" },
|
|
728
|
+
// populated: { state: "STATE-${slug}-<key>-populated" },
|
|
729
|
+
// overflowing: { state: "STATE-${slug}-<key>-overflowing" },
|
|
730
|
+
// recoverableError: { state: "STATE-${slug}-<key>-retry" },
|
|
731
|
+
// terminalError: { state: "STATE-${slug}-<key>-failed" },
|
|
732
|
+
// permissionDenied: { state: "STATE-${slug}-<key>-read-only" },
|
|
733
|
+
// stale: { waived: "Reads are live.", review: "2027-01-01" },
|
|
734
|
+
// inFlight: { state: "STATE-${slug}-<key>-saving" },
|
|
735
|
+
// terminalSuccess: { waived: "A place, not a flow that finishes." },
|
|
736
|
+
// conflict: { state: "STATE-${slug}-<key>-conflict" },
|
|
737
|
+
// },
|
|
738
|
+
// },
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
// What each declared state *is*, in one line -- said where it is declared,
|
|
742
|
+
// because the board is read from here until /render-states, and a state
|
|
743
|
+
// whose only name is its ID is a state nobody can review. Say what the
|
|
744
|
+
// person is looking at, not which row it answers.
|
|
745
|
+
states: {
|
|
746
|
+
// "STATE-${slug}-<key>-empty": "Nothing here yet, and one button to start",
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
cases: {},
|
|
750
|
+
|
|
751
|
+
flows: [
|
|
752
|
+
// {
|
|
753
|
+
// id: "JOURNEY-${slug}-<intent>",
|
|
754
|
+
// title: "What the actor gets",
|
|
755
|
+
// actor: "Someone", // must match a bolded actor in BRIEF.md
|
|
756
|
+
// spine: [
|
|
757
|
+
// { case: "STATE-${slug}-<key>-empty", on: "Does the first thing" },
|
|
758
|
+
// { case: "STATE-${slug}-<key>-populated", end: "What they are left with." },
|
|
759
|
+
// ],
|
|
760
|
+
// deviations: [
|
|
761
|
+
// { from: "STATE-${slug}-<key>-populated", when: "Cold cache", case: "STATE-${slug}-<key>-loading", rejoins: "STATE-${slug}-<key>-populated" },
|
|
762
|
+
// ],
|
|
763
|
+
// },
|
|
764
|
+
],
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
// Keep the imports live so the skeleton typechecks before any case uses them.
|
|
768
|
+
void copy
|
|
769
|
+
void fixtures
|
|
770
|
+
void sketches
|
|
771
|
+
`;
|
|
772
|
+
var copyTs = (slug) => `import { defineCopy } from "@redspec/core"
|
|
773
|
+
|
|
774
|
+
// Every user-facing string this feature ships, once. Sketches render from it;
|
|
775
|
+
// assertions assert against it. A word changes here and both readers see it.
|
|
776
|
+
export const copy = defineCopy({
|
|
777
|
+
// "COPY-${slug}-<key>-empty-title": "Nothing here yet",
|
|
778
|
+
})
|
|
779
|
+
`;
|
|
780
|
+
var fixturesTs = () => `// Fixtures for the cases. Plain data, no network, no database: a case that
|
|
781
|
+
// reaches for either is a Journey wearing a State's clothes.
|
|
782
|
+
export {}
|
|
783
|
+
`;
|
|
784
|
+
var sketchesTsx = () => `// Sketch markup for the cases. Drafts: a slice promotes them into components/
|
|
785
|
+
// and the assertions survive unchanged, which is why those are written in
|
|
786
|
+
// user intent rather than against a selector.
|
|
787
|
+
export {}
|
|
788
|
+
`;
|
|
789
|
+
var stateSpec = (slug) => `import { expect, test } from "@playwright/test"
|
|
790
|
+
import { copy } from "../../specs/${slug}/copy"
|
|
791
|
+
|
|
792
|
+
// One behavioural assertion and one screenshot per state, named for its ID,
|
|
793
|
+
// written in user intent. \`redspec new state <ID>\` appends here.
|
|
794
|
+
void copy
|
|
795
|
+
void expect
|
|
796
|
+
void test
|
|
797
|
+
`;
|
|
798
|
+
var journeySpecHeader = (slug) => `import { test } from "@playwright/test"
|
|
799
|
+
|
|
800
|
+
// Generated by \`redspec new journeys ${slug}\` from the flows in spec.ts \u2014 one
|
|
801
|
+
// per reachable path. Regenerate rather than edit. Each stays fixme until the
|
|
802
|
+
// slice that claims its JOURNEY- lands and un-fixmes it.
|
|
803
|
+
`;
|
|
804
|
+
var journeyTest = (id, index, states, labels, end) => {
|
|
805
|
+
const steps = states.map((s, i) => labels[i] ? ` // ${s}
|
|
806
|
+
// \u2192 ${labels[i]}` : ` // ${s}`).join("\n");
|
|
807
|
+
return `
|
|
808
|
+
test.fixme("${id} [path ${index + 1}]: ${end.replace(/"/g, '\\"')}", async ({ page }) => {
|
|
809
|
+
${steps}
|
|
810
|
+
// Ends: ${end}
|
|
811
|
+
await page.goto("/")
|
|
812
|
+
})
|
|
813
|
+
`;
|
|
814
|
+
};
|
|
815
|
+
var stateFixture = (id) => `
|
|
816
|
+
// ${id}
|
|
817
|
+
export const ${camel(id)} = {}
|
|
818
|
+
`;
|
|
819
|
+
var stateSketch = (id, component) => `
|
|
820
|
+
// ${id}
|
|
821
|
+
export function ${component}() {
|
|
822
|
+
return <div>{/* ${id} */}</div>
|
|
823
|
+
}
|
|
824
|
+
`;
|
|
825
|
+
var stateAssertion = (id) => `
|
|
826
|
+
test("${id} <what a reviewer would say out loud about it>", async ({ page }) => {
|
|
827
|
+
await page.goto("/spec/<slug>/${id}")
|
|
828
|
+
// await expect(page.getByText(copy["COPY-\u2026"])).toBeVisible()
|
|
829
|
+
await expect(page).toHaveScreenshot("${id}.png")
|
|
830
|
+
})
|
|
831
|
+
`;
|
|
832
|
+
var caseSnippet = (id, surface, component, fixture) => ` "${id}": {
|
|
833
|
+
surface: "${surface}",
|
|
834
|
+
render: () => <sketches.${component} {...fixtures.${fixture}} />,
|
|
835
|
+
},`;
|
|
836
|
+
var stateNameSnippet = (id) => ` "${id}": "<what the person is looking at>",`;
|
|
837
|
+
function camel(id) {
|
|
838
|
+
return id.replace(/^(STATE|RULE|INV|JOURNEY|COPY)-/, "").split("-").map((p2, i) => i === 0 ? p2 : p2[0].toUpperCase() + p2.slice(1)).join("");
|
|
839
|
+
}
|
|
840
|
+
function pascal(id) {
|
|
841
|
+
const c = camel(id);
|
|
842
|
+
return c[0].toUpperCase() + c.slice(1);
|
|
843
|
+
}
|
|
844
|
+
var ruleStub = (id) => `# ${id}
|
|
845
|
+
|
|
846
|
+
What this rule decides, in one sentence.
|
|
847
|
+
|
|
848
|
+
| Input | Output | Why |
|
|
849
|
+
| --- | --- | --- |
|
|
850
|
+
| the figure the person gave | in their words | their reason |
|
|
851
|
+
|
|
852
|
+
**Status:** stub. /implement-rules picks the rung.
|
|
853
|
+
`;
|
|
854
|
+
var ruleTable = (id) => `## ${id}
|
|
855
|
+
|
|
856
|
+
What this rule decides, in one sentence.
|
|
857
|
+
|
|
858
|
+
**Inputs:** amount: number(0..), plan: {free, pro}
|
|
859
|
+
**Hit policy:** UNIQUE
|
|
860
|
+
|
|
861
|
+
| amount | plan | outcome | note |
|
|
862
|
+
| -------- | ---- | ------- | ---- |
|
|
863
|
+
| [0..100] | - | allowed | under the limit |
|
|
864
|
+
| (100..] | free | blocked | free plans stop at 100 |
|
|
865
|
+
| (100..] | pro | allowed | pro plans have no limit |
|
|
866
|
+
|
|
867
|
+
<!-- \`redspec check\` proves this total and non-overlapping. Drive it from a test with
|
|
868
|
+
parseDecisionTable + decide + representativeInputs from @redspec/core. -->
|
|
869
|
+
`;
|
|
870
|
+
var ruleTableTest = (id) => `import { readFileSync } from "node:fs"
|
|
871
|
+
import { join } from "node:path"
|
|
872
|
+
import { describe, expect, it } from "vitest"
|
|
873
|
+
import { decide, parseDecisionTable, representativeInputs } from "@redspec/core"
|
|
874
|
+
|
|
875
|
+
// ${id}: the markdown table is the artifact a reviewer signs; this is plumbing.
|
|
876
|
+
const table = parseDecisionTable(readFileSync(join(import.meta.dirname, "${id}.md"), "utf8"))
|
|
877
|
+
|
|
878
|
+
// Replace with the real implementation under test.
|
|
879
|
+
const implementation = (input: Record<string, number | string | boolean>) => decide(table, input)
|
|
880
|
+
|
|
881
|
+
describe("${id}", () => {
|
|
882
|
+
it("agrees with the table in every region it distinguishes", () => {
|
|
883
|
+
for (const input of representativeInputs(table)) {
|
|
884
|
+
expect(implementation(input)).toEqual(decide(table, input))
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
})
|
|
888
|
+
`;
|
|
889
|
+
var ruleMachine = (id) => `// ${id}
|
|
890
|
+
//
|
|
891
|
+
// A lifecycle as an explicit states \xD7 events table. The empty cells are the
|
|
892
|
+
// point: an undefined transition is visible by inspection.
|
|
893
|
+
|
|
894
|
+
export type State = "draft" | "submitted" | "approved"
|
|
895
|
+
export type Event = "submit" | "approve" | "reject"
|
|
896
|
+
|
|
897
|
+
export const machine = {
|
|
898
|
+
draft: { submit: "submitted" },
|
|
899
|
+
submitted: { approve: "approved", reject: "draft" },
|
|
900
|
+
approved: {},
|
|
901
|
+
} as const satisfies Record<State, Partial<Record<Event, State>>>
|
|
902
|
+
|
|
903
|
+
export function next(state: State, event: Event): State | null {
|
|
904
|
+
return (machine[state] as Partial<Record<Event, State>>)[event] ?? null
|
|
905
|
+
}
|
|
906
|
+
`;
|
|
907
|
+
var ruleMachineTest = (id) => `import fc from "fast-check"
|
|
908
|
+
import { describe, expect, it } from "vitest"
|
|
909
|
+
import { machine, next, type Event, type State } from "./${id}"
|
|
910
|
+
|
|
911
|
+
// ${id}: two tests. The shape test proves the table is well-formed; the
|
|
912
|
+
// model-based run proves the implementation *is* the table.
|
|
913
|
+
describe("${id}", () => {
|
|
914
|
+
it("names every state", () => {
|
|
915
|
+
const states: State[] = ["draft", "submitted", "approved"]
|
|
916
|
+
for (const s of states) expect(machine).toHaveProperty(s)
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it("the implementation tracks the table across random legal event sequences", () => {
|
|
920
|
+
const events: Event[] = ["submit", "approve", "reject"]
|
|
921
|
+
fc.assert(
|
|
922
|
+
fc.property(fc.array(fc.constantFrom(...events), { maxLength: 20 }), (seq) => {
|
|
923
|
+
let model: State = "draft"
|
|
924
|
+
// Replace \`sut\` with the real system under test and step it alongside the model.
|
|
925
|
+
let sut: State = "draft"
|
|
926
|
+
for (const e of seq) {
|
|
927
|
+
const to = next(model, e)
|
|
928
|
+
if (to === null) continue // illegal in the model: the SUT must refuse it too
|
|
929
|
+
model = to
|
|
930
|
+
sut = to
|
|
931
|
+
expect(sut).toBe(model)
|
|
932
|
+
}
|
|
933
|
+
})
|
|
934
|
+
)
|
|
935
|
+
})
|
|
936
|
+
})
|
|
937
|
+
`;
|
|
938
|
+
var ruleInvariant = (id) => `import fc from "fast-check"
|
|
939
|
+
import { describe, expect, it } from "vitest"
|
|
940
|
+
|
|
941
|
+
// ${id}: a sentence about the domain that admits no exception.
|
|
942
|
+
describe("${id}", () => {
|
|
943
|
+
it("holds for every input", () => {
|
|
944
|
+
fc.assert(
|
|
945
|
+
fc.property(fc.integer(), (n) => {
|
|
946
|
+
expect(n + 0).toBe(n) // replace with the property
|
|
947
|
+
})
|
|
948
|
+
)
|
|
949
|
+
})
|
|
950
|
+
})
|
|
951
|
+
`;
|
|
952
|
+
var ruleType = (id) => `// ${id}
|
|
953
|
+
//
|
|
954
|
+
// Make the illegal state unrepresentable and the rule needs no test.
|
|
955
|
+
export type Example =
|
|
956
|
+
| { status: "draft" }
|
|
957
|
+
| { status: "sent"; sentAt: Date }
|
|
958
|
+
`;
|
|
959
|
+
var slice = (title2, claims, amends) => `# ${title2}
|
|
960
|
+
|
|
961
|
+
**Delivers:** the end-to-end behaviour this makes work, from the user's perspective.
|
|
962
|
+
|
|
963
|
+
**Blocked by:** None.
|
|
964
|
+
${amends.length ? `
|
|
965
|
+
**Amends:**
|
|
966
|
+
|
|
967
|
+
${amends.map((a) => `- \`${a}\``).join("\n")}
|
|
968
|
+
|
|
969
|
+
**Because:** why the requirement moved.
|
|
970
|
+
` : ""}
|
|
971
|
+
**Claims:**
|
|
972
|
+
|
|
973
|
+
${claims.map((c) => `- \`${c}\``).join("\n") || "- `STATE-\u2026`"}
|
|
974
|
+
|
|
975
|
+
**Status:** ready
|
|
976
|
+
`;
|
|
977
|
+
|
|
978
|
+
// src/commands/init.ts
|
|
979
|
+
async function init(opts) {
|
|
980
|
+
const root = opts.root;
|
|
981
|
+
const log = opts.quiet ? () => {
|
|
982
|
+
} : console.log;
|
|
983
|
+
const pkgPath = join7(root, "package.json");
|
|
984
|
+
const pkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
|
|
985
|
+
if (!pkg)
|
|
986
|
+
throw new Error(
|
|
987
|
+
`No package.json in ${root}. Run redspec init at the root of a Node project.`
|
|
988
|
+
);
|
|
989
|
+
const detectedFramework = opts.framework ?? detectFramework(pkg);
|
|
990
|
+
const detectedHarnesses = detectHarnesses(root);
|
|
991
|
+
const runners = hasTestRunner(pkg);
|
|
992
|
+
const pm = packageManager(root);
|
|
993
|
+
let harnesses;
|
|
994
|
+
let framework = detectedFramework;
|
|
995
|
+
if (opts.harness !== void 0) {
|
|
996
|
+
harnesses = opts.harness.split(",").map((h) => h.trim()).filter((h) => HARNESSES2.includes(h));
|
|
997
|
+
} else if (opts.yes) {
|
|
998
|
+
const existing = existsSync7(join7(root, "spec.config.ts")) ? (await loadConfig2(root)).config.harnesses : null;
|
|
999
|
+
harnesses = existing ?? detectedHarnesses.map((d) => d.harness);
|
|
1000
|
+
} else {
|
|
1001
|
+
p.intro(pc7.bgCyan(pc7.black(" redspec ")));
|
|
1002
|
+
p.note(
|
|
1003
|
+
[
|
|
1004
|
+
`framework ${framework === "next" ? pc7.green("Next.js") : pc7.yellow("none detected \u2014 core only, no board")}`,
|
|
1005
|
+
`unit tests ${runners.unit ?? pc7.yellow("none \u2014 vitest will be added")}`,
|
|
1006
|
+
`browser ${runners.browser ?? pc7.yellow("none \u2014 @playwright/test will be added")}`,
|
|
1007
|
+
"",
|
|
1008
|
+
...HARNESSES2.map((h) => {
|
|
1009
|
+
const d = detectedHarnesses.find((x) => x.harness === h);
|
|
1010
|
+
return `${d ? pc7.green("\u2713") : pc7.dim("\u25CB")} ${h.padEnd(9)} ${d ? pc7.dim(d.evidence) : ""}`;
|
|
1011
|
+
})
|
|
1012
|
+
].join("\n"),
|
|
1013
|
+
"Detected"
|
|
1014
|
+
);
|
|
1015
|
+
const picked = await p.multiselect({
|
|
1016
|
+
message: "Write agent context for which harnesses?",
|
|
1017
|
+
options: HARNESSES2.map((h) => ({
|
|
1018
|
+
value: h,
|
|
1019
|
+
label: h,
|
|
1020
|
+
hint: CAPABILITIES2[h].hitlOnly ? "steps are HITL-only" : "steps are conventions; CI is the guardrail"
|
|
1021
|
+
})),
|
|
1022
|
+
initialValues: detectedHarnesses.map((d) => d.harness),
|
|
1023
|
+
required: false
|
|
1024
|
+
});
|
|
1025
|
+
if (p.isCancel(picked)) {
|
|
1026
|
+
p.cancel("Nothing written.");
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
1029
|
+
harnesses = picked;
|
|
1030
|
+
if (framework === "none") {
|
|
1031
|
+
const go = await p.confirm({
|
|
1032
|
+
message: "No supported framework found. Set up core only (specs, rules, lock, coverage \u2014 no spec route or board)?"
|
|
1033
|
+
});
|
|
1034
|
+
if (p.isCancel(go) || !go) {
|
|
1035
|
+
p.cancel("Nothing written.");
|
|
1036
|
+
process.exit(1);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const w = new Writer(root);
|
|
1041
|
+
const config = defineSpecConfig({ framework, harnesses });
|
|
1042
|
+
w.create("spec.config.ts", specConfig(framework, harnesses));
|
|
1043
|
+
w.create("specs/index.ts", specsIndex);
|
|
1044
|
+
w.create("specs/.gitkeep", "");
|
|
1045
|
+
w.create("e2e/state/.gitkeep", "");
|
|
1046
|
+
w.create("e2e/journey/.gitkeep", "");
|
|
1047
|
+
w.create("e2e/screenshot.css", screenshotCss);
|
|
1048
|
+
if (!runners.browser) w.create("playwright.config.ts", playwrightConfig);
|
|
1049
|
+
w.create(".github/workflows/redspec.yml", ciWorkflow);
|
|
1050
|
+
if (framework === "next") {
|
|
1051
|
+
const src = existsSync7(join7(root, "src/app")) ? "src/" : "";
|
|
1052
|
+
if (existsSync7(join7(root, `${src}proxy.ts`)) || existsSync7(join7(root, `${src}middleware.ts`))) {
|
|
1053
|
+
w.create(`${src}proxy.redspec.ts`, proxy);
|
|
1054
|
+
log(
|
|
1055
|
+
pc7.yellow(
|
|
1056
|
+
` A proxy/middleware already exists. Wrote ${src}proxy.redspec.ts \u2014 merge its gate into yours.`
|
|
1057
|
+
)
|
|
1058
|
+
);
|
|
1059
|
+
} else {
|
|
1060
|
+
w.create(`${src}proxy.ts`, proxy);
|
|
1061
|
+
}
|
|
1062
|
+
w.create(`${src}app/spec/_routes.ts`, nextRoutes(config));
|
|
1063
|
+
w.create(`${src}app/spec/layout.tsx`, nextLayout);
|
|
1064
|
+
w.create(`${src}app/spec/page.tsx`, nextIndex);
|
|
1065
|
+
w.create(`${src}app/spec/[feature]/page.tsx`, nextBoard);
|
|
1066
|
+
w.create(`${src}app/spec/[feature]/[case]/page.tsx`, nextCase);
|
|
1067
|
+
}
|
|
1068
|
+
const scripts = pkg.scripts ?? {};
|
|
1069
|
+
const add = {
|
|
1070
|
+
spec: "redspec check",
|
|
1071
|
+
"spec:status": "redspec status",
|
|
1072
|
+
"test:state": "playwright test --project=state",
|
|
1073
|
+
"test:journey": "playwright test --project=journey"
|
|
1074
|
+
};
|
|
1075
|
+
if (!runners.unit) add.test = "vitest run";
|
|
1076
|
+
let changed = false;
|
|
1077
|
+
for (const [k, v] of Object.entries(add)) {
|
|
1078
|
+
if (!scripts[k]) {
|
|
1079
|
+
scripts[k] = v;
|
|
1080
|
+
changed = true;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (changed) {
|
|
1084
|
+
pkg.scripts = scripts;
|
|
1085
|
+
writeFileSync2(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
1086
|
+
w.written.push({ path: "package.json", action: "updated" });
|
|
1087
|
+
}
|
|
1088
|
+
const synced = sync(root, config, harnesses);
|
|
1089
|
+
w.written.push(...synced.written);
|
|
1090
|
+
const runtime = ["@redspec/core", "@redspec/cli"];
|
|
1091
|
+
if (framework === "next") runtime.push("@redspec/next");
|
|
1092
|
+
const dev = ["fast-check"];
|
|
1093
|
+
if (!runners.unit) dev.push("vitest");
|
|
1094
|
+
if (!runners.browser) dev.push("@playwright/test");
|
|
1095
|
+
const needRuntime = missingDeps(root, runtime);
|
|
1096
|
+
const needDev = missingDeps(root, dev);
|
|
1097
|
+
const installLines = [
|
|
1098
|
+
...needRuntime.length ? [installCommand(pm, needRuntime, false)] : [],
|
|
1099
|
+
...needDev.length ? [installCommand(pm, needDev, true)] : []
|
|
1100
|
+
];
|
|
1101
|
+
if (!opts.quiet) {
|
|
1102
|
+
for (const f of w.written) {
|
|
1103
|
+
if (f.action === "kept") continue;
|
|
1104
|
+
log(
|
|
1105
|
+
` ${f.action === "created" ? pc7.green("create") : pc7.cyan("update")} ${f.path}`
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const installed = opts.skipInstall ? false : installDeps(root, pm, needRuntime, false, log) && installDeps(root, pm, needDev, true, log);
|
|
1110
|
+
if (!opts.quiet) {
|
|
1111
|
+
log("");
|
|
1112
|
+
log(pc7.bold("Next:"));
|
|
1113
|
+
if (installLines.length && !installed) for (const l of installLines) log(` ${l}`);
|
|
1114
|
+
log(` ${pm === "npm" ? "npx" : `${pm} exec`} redspec doctor`);
|
|
1115
|
+
if (harnesses.length) {
|
|
1116
|
+
log("");
|
|
1117
|
+
for (const h of harnesses) {
|
|
1118
|
+
log(` ${pc7.dim(h.padEnd(9))} ${CAPABILITIES2[h].note}`);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
log("");
|
|
1122
|
+
log(pc7.dim("Then: /draft-skeleton <an idea, at whatever resolution you have it>"));
|
|
1123
|
+
}
|
|
1124
|
+
return { writer: w, install: installLines, harnesses, framework };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// src/commands/new.ts
|
|
1128
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
|
|
1129
|
+
import { join as join8 } from "path";
|
|
1130
|
+
import pc8 from "picocolors";
|
|
1131
|
+
import { compileFlow, ID_PATTERN, simplePaths } from "@redspec/core";
|
|
1132
|
+
var title = (slug) => slug.split("-").map((p2) => p2[0].toUpperCase() + p2.slice(1)).join(" ");
|
|
1133
|
+
async function newFeature(root, slug, quiet = false) {
|
|
1134
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
|
1135
|
+
console.error(pc8.red(`"${slug}" is not a slug. Lowercase, hyphenated.`));
|
|
1136
|
+
return 1;
|
|
1137
|
+
}
|
|
1138
|
+
const ctx = await loadContext(root);
|
|
1139
|
+
const dir = join8(ctx.config.specsDir, slug);
|
|
1140
|
+
const w = new Writer(root);
|
|
1141
|
+
w.create(`${dir}/BRIEF.md`, brief(slug, title(slug)));
|
|
1142
|
+
w.create(`${dir}/${ctx.config.specFile}`, specTs(slug, title(slug)));
|
|
1143
|
+
w.create(`${dir}/copy.ts`, copyTs(slug));
|
|
1144
|
+
w.create(`${dir}/fixtures.ts`, fixturesTs());
|
|
1145
|
+
w.create(`${dir}/sketches.tsx`, sketchesTsx());
|
|
1146
|
+
w.create(`${dir}/rules/.gitkeep`, "");
|
|
1147
|
+
w.create(`${dir}/slices/.gitkeep`, "");
|
|
1148
|
+
w.create(`${ctx.config.stateTestsDir}/${slug}.spec.ts`, stateSpec(slug));
|
|
1149
|
+
w.create(`${ctx.config.journeyTestsDir}/${slug}.spec.ts`, journeySpecHeader(slug));
|
|
1150
|
+
const indexPath = join8(root, ctx.config.specsDir, "index.ts");
|
|
1151
|
+
if (existsSync8(indexPath)) {
|
|
1152
|
+
const ident = slug.replace(/-(\w)/g, (_, c) => c.toUpperCase()) + "Spec";
|
|
1153
|
+
let src = readFileSync6(indexPath, "utf8");
|
|
1154
|
+
if (!src.includes(`./${slug}/spec`)) {
|
|
1155
|
+
src = src.replace(
|
|
1156
|
+
"// redspec:imports",
|
|
1157
|
+
`import ${ident} from "./${slug}/spec"
|
|
1158
|
+
// redspec:imports`
|
|
1159
|
+
);
|
|
1160
|
+
src = src.replace(" // redspec:specs", ` ${ident},
|
|
1161
|
+
// redspec:specs`);
|
|
1162
|
+
writeFileSync3(indexPath, src);
|
|
1163
|
+
w.written.push({ path: `${ctx.config.specsDir}/index.ts`, action: "updated" });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
report(w, quiet);
|
|
1167
|
+
if (!quiet)
|
|
1168
|
+
console.log(
|
|
1169
|
+
pc8.dim(
|
|
1170
|
+
`
|
|
1171
|
+
Now fill ${dir}/BRIEF.md and declare surfaces and flows in ${dir}/${ctx.config.specFile}. \`redspec status\` is the work list.`
|
|
1172
|
+
)
|
|
1173
|
+
);
|
|
1174
|
+
return 0;
|
|
1175
|
+
}
|
|
1176
|
+
async function newState(root, id, opts) {
|
|
1177
|
+
if (!ID_PATTERN.test(id) || !id.startsWith("STATE-")) {
|
|
1178
|
+
console.error(pc8.red(`"${id}" is not a STATE- ID. Lowercase, hyphenated.`));
|
|
1179
|
+
return 1;
|
|
1180
|
+
}
|
|
1181
|
+
const ctx = await loadContext(root);
|
|
1182
|
+
const owner = ctx.specs.find((s) => id.startsWith(`STATE-${s.spec.slug}-`));
|
|
1183
|
+
if (!owner) {
|
|
1184
|
+
console.error(
|
|
1185
|
+
pc8.red(
|
|
1186
|
+
`No feature's slug prefixes "${id}". Features: ${ctx.specs.map((s) => s.spec.slug).join(", ") || "none"}.`
|
|
1187
|
+
)
|
|
1188
|
+
);
|
|
1189
|
+
return 1;
|
|
1190
|
+
}
|
|
1191
|
+
const slug = owner.spec.slug;
|
|
1192
|
+
const declared = ctx.reports.find((r) => r.slug === slug).artifacts.some((a) => a.id === id);
|
|
1193
|
+
if (!declared)
|
|
1194
|
+
console.log(
|
|
1195
|
+
pc8.yellow(
|
|
1196
|
+
` "${id}" is not declared by any checklist row or flow yet. Declare it in ${ctx.config.specFile} too, or the audit will call it unclaimed.`
|
|
1197
|
+
)
|
|
1198
|
+
);
|
|
1199
|
+
const rel = (f) => join8(ctx.config.specsDir, slug, f);
|
|
1200
|
+
const local = id.slice(`STATE-${slug}-`.length);
|
|
1201
|
+
const component = pascal(local);
|
|
1202
|
+
const fixture = camel(local);
|
|
1203
|
+
const w = new Writer(root);
|
|
1204
|
+
if (!(w.read(rel("fixtures.ts")) ?? "").includes(`// ${id}`))
|
|
1205
|
+
w.append(rel("fixtures.ts"), stateFixture(id));
|
|
1206
|
+
if (!(w.read(rel("sketches.tsx")) ?? "").includes(`// ${id}`))
|
|
1207
|
+
w.append(rel("sketches.tsx"), stateSketch(id, component));
|
|
1208
|
+
const testPath = `${ctx.config.stateTestsDir}/${slug}.spec.ts`;
|
|
1209
|
+
if (!(w.read(testPath) ?? "").includes(`"${id} `))
|
|
1210
|
+
w.append(testPath, stateAssertion(id).replace("<slug>", slug));
|
|
1211
|
+
report(w, opts.quiet);
|
|
1212
|
+
const surface = opts.surface ?? Object.keys(owner.spec.surfaces)[0] ?? "<surface>";
|
|
1213
|
+
if (!opts.quiet) {
|
|
1214
|
+
console.log(`
|
|
1215
|
+
Add to \`states\` in ${rel(ctx.config.specFile)}:
|
|
1216
|
+
`);
|
|
1217
|
+
console.log(stateNameSnippet(id));
|
|
1218
|
+
console.log(`
|
|
1219
|
+
And to \`cases\`:
|
|
1220
|
+
`);
|
|
1221
|
+
console.log(caseSnippet(id, surface, component, fixture));
|
|
1222
|
+
console.log(
|
|
1223
|
+
pc8.dim(
|
|
1224
|
+
"\nThen fill the fixture, the sketch (strings via copy.ts), and the assertion (user intent, no selectors)."
|
|
1225
|
+
)
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
return 0;
|
|
1229
|
+
}
|
|
1230
|
+
async function newRule(root, id, opts) {
|
|
1231
|
+
if (!ID_PATTERN.test(id) || !/^(RULE|INV)-/.test(id)) {
|
|
1232
|
+
console.error(pc8.red(`"${id}" is not a RULE- or INV- ID.`));
|
|
1233
|
+
return 1;
|
|
1234
|
+
}
|
|
1235
|
+
const ctx = await loadContext(root);
|
|
1236
|
+
const slug = opts.feature ?? (ctx.specs.length === 1 ? ctx.specs[0].spec.slug : void 0);
|
|
1237
|
+
if (!slug) {
|
|
1238
|
+
console.error(pc8.red("Pass --feature <slug>; more than one feature is declared."));
|
|
1239
|
+
return 1;
|
|
1240
|
+
}
|
|
1241
|
+
const dir = join8(ctx.config.specsDir, slug, "rules");
|
|
1242
|
+
const w = new Writer(root);
|
|
1243
|
+
switch (opts.form) {
|
|
1244
|
+
case "stub":
|
|
1245
|
+
w.create(`${dir}/${id}.md`, ruleStub(id));
|
|
1246
|
+
break;
|
|
1247
|
+
case "table":
|
|
1248
|
+
w.create(`${dir}/${id}.md`, ruleTable(id));
|
|
1249
|
+
w.create(`${dir}/${id}.test.ts`, ruleTableTest(id));
|
|
1250
|
+
break;
|
|
1251
|
+
case "machine":
|
|
1252
|
+
w.create(`${dir}/${id}.ts`, ruleMachine(id));
|
|
1253
|
+
w.create(`${dir}/${id}.test.ts`, ruleMachineTest(id));
|
|
1254
|
+
break;
|
|
1255
|
+
case "invariant":
|
|
1256
|
+
w.create(`${dir}/${id}.test.ts`, ruleInvariant(id));
|
|
1257
|
+
w.create(`${dir}/${id}.ts`, `// ${id}: see ${id}.test.ts
|
|
1258
|
+
export {}
|
|
1259
|
+
`);
|
|
1260
|
+
break;
|
|
1261
|
+
case "type":
|
|
1262
|
+
w.create(`${dir}/${id}.ts`, ruleType(id));
|
|
1263
|
+
break;
|
|
1264
|
+
default:
|
|
1265
|
+
console.error(pc8.red(`--form must be stub, table, machine, invariant, or type.`));
|
|
1266
|
+
return 1;
|
|
1267
|
+
}
|
|
1268
|
+
report(w, opts.quiet);
|
|
1269
|
+
return 0;
|
|
1270
|
+
}
|
|
1271
|
+
async function newSlice(root, slug, name, opts) {
|
|
1272
|
+
const ctx = await loadContext(root);
|
|
1273
|
+
if (!ctx.specs.some((s) => s.spec.slug === slug)) {
|
|
1274
|
+
console.error(pc8.red(`No feature "${slug}".`));
|
|
1275
|
+
return 1;
|
|
1276
|
+
}
|
|
1277
|
+
if (!/^A?\d{2}-[a-z0-9-]+$/.test(name)) {
|
|
1278
|
+
console.error(
|
|
1279
|
+
pc8.red(
|
|
1280
|
+
`Slice files are <NN>-<name> (or A<NN>-<name> for an amendment): "${name}" is not.`
|
|
1281
|
+
)
|
|
1282
|
+
);
|
|
1283
|
+
return 1;
|
|
1284
|
+
}
|
|
1285
|
+
const heading = `${name.split("-")[0]}: ${title(name.split("-").slice(1).join("-"))}`;
|
|
1286
|
+
const w = new Writer(root);
|
|
1287
|
+
w.create(
|
|
1288
|
+
join8(ctx.config.specsDir, slug, "slices", `${name}.md`),
|
|
1289
|
+
slice(heading, opts.claims, opts.amends)
|
|
1290
|
+
);
|
|
1291
|
+
report(w, opts.quiet);
|
|
1292
|
+
return 0;
|
|
1293
|
+
}
|
|
1294
|
+
async function newJourneys(root, slug, quiet = false) {
|
|
1295
|
+
const ctx = await loadContext(root);
|
|
1296
|
+
const owner = ctx.specs.find((s) => s.spec.slug === slug);
|
|
1297
|
+
if (!owner) {
|
|
1298
|
+
console.error(pc8.red(`No feature "${slug}".`));
|
|
1299
|
+
return 1;
|
|
1300
|
+
}
|
|
1301
|
+
let out = journeySpecHeader(slug);
|
|
1302
|
+
let n = 0;
|
|
1303
|
+
for (const flow of owner.spec.flows) {
|
|
1304
|
+
const { paths, truncated } = simplePaths(compileFlow(flow), ctx.config.journeyBudget);
|
|
1305
|
+
paths.forEach((path, i) => {
|
|
1306
|
+
out += journeyTest(flow.id, i, path.states, path.labels, path.end);
|
|
1307
|
+
n++;
|
|
1308
|
+
});
|
|
1309
|
+
if (truncated)
|
|
1310
|
+
out += `
|
|
1311
|
+
// ${flow.id}: path enumeration stopped at the budget (${ctx.config.journeyBudget}). Raise journeyBudget in spec.config.ts, or simplify the flow.
|
|
1312
|
+
`;
|
|
1313
|
+
}
|
|
1314
|
+
const w = new Writer(root);
|
|
1315
|
+
w.write(`${ctx.config.journeyTestsDir}/${slug}.spec.ts`, out);
|
|
1316
|
+
report(w, quiet);
|
|
1317
|
+
if (!quiet)
|
|
1318
|
+
console.log(
|
|
1319
|
+
pc8.dim(
|
|
1320
|
+
`${n} path${n === 1 ? "" : "s"} across ${owner.spec.flows.length} flow${owner.spec.flows.length === 1 ? "" : "s"}.`
|
|
1321
|
+
)
|
|
1322
|
+
);
|
|
1323
|
+
return 0;
|
|
1324
|
+
}
|
|
1325
|
+
function report(w, quiet) {
|
|
1326
|
+
if (quiet) return;
|
|
1327
|
+
for (const f of w.written) {
|
|
1328
|
+
if (f.action === "kept") continue;
|
|
1329
|
+
console.log(
|
|
1330
|
+
` ${f.action === "created" ? pc8.green("create") : pc8.cyan("update")} ${f.path}`
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// src/commands/status.ts
|
|
1336
|
+
async function status(root, opts = {}) {
|
|
1337
|
+
const ctx = await loadContext(root);
|
|
1338
|
+
if (opts.ids !== void 0) {
|
|
1339
|
+
const reports = opts.ids ? ctx.reports.filter((r) => r.slug === opts.ids) : ctx.reports;
|
|
1340
|
+
for (const r of reports) for (const a of r.artifacts) console.log(a.id);
|
|
1341
|
+
return 0;
|
|
1342
|
+
}
|
|
1343
|
+
printStatus(ctx);
|
|
1344
|
+
return 0;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// src/index.ts
|
|
1348
|
+
function program() {
|
|
1349
|
+
const cli = new Command("redspec").description("Specs made of artifacts that can go red.").option("-C, --cwd <dir>", "repo root", process.cwd()).exitOverride();
|
|
1350
|
+
const root = () => cli.opts().cwd;
|
|
1351
|
+
cli.command("init").description(
|
|
1352
|
+
"Set this repo up: config, spec route, test tiers, and agent context for the harnesses you use."
|
|
1353
|
+
).option("-y, --yes", "accept detected defaults, no prompts").option("--harness <list>", "comma-separated: claude,cursor,codex,copilot,gemini").option("--framework <name>", "next | none").option("--skip-install", "write the files, install nothing").action(
|
|
1354
|
+
async (o) => {
|
|
1355
|
+
await init({ root: root(), ...o });
|
|
1356
|
+
}
|
|
1357
|
+
);
|
|
1358
|
+
cli.command("check").description(
|
|
1359
|
+
"The gate: audit, coverage, decision tables, lock. Exit 1 on any finding."
|
|
1360
|
+
).option("--json", "machine-readable").option("-q, --quiet", "only the summary line").action(
|
|
1361
|
+
async (o) => process.exit(await check(root(), o))
|
|
1362
|
+
);
|
|
1363
|
+
cli.command("status").description("The work list, in English.").option("--ids [slug]", "list artifact IDs (optionally for one feature) and exit").action(
|
|
1364
|
+
async (o) => process.exit(
|
|
1365
|
+
await status(root(), {
|
|
1366
|
+
ids: o.ids === true ? "" : o.ids === false ? void 0 : o.ids
|
|
1367
|
+
})
|
|
1368
|
+
)
|
|
1369
|
+
);
|
|
1370
|
+
const n = cli.command("new").description("Scaffold an artifact. Never hand-write what this writes.");
|
|
1371
|
+
n.command("feature <slug>").description(
|
|
1372
|
+
"a bundle: BRIEF, spec.ts, copy, fixtures, sketches, rules/, slices/, both test files"
|
|
1373
|
+
).action(async (slug) => process.exit(await newFeature(root(), slug)));
|
|
1374
|
+
n.command("state <id>").description("fixture, sketch, and assertion scaffolds for a declared STATE-").option("--surface <key>").action(
|
|
1375
|
+
async (id, o) => process.exit(await newState(root(), id, o))
|
|
1376
|
+
);
|
|
1377
|
+
n.command("rule <id>").description("a rule on a rung").requiredOption("--form <form>", "stub | table | machine | invariant | type").option("--feature <slug>").action(
|
|
1378
|
+
async (id, o) => process.exit(await newRule(root(), id, o))
|
|
1379
|
+
);
|
|
1380
|
+
n.command("slice <slug> <name>").description("a slice file: <NN>-<name>, or A<NN>-<name> for an amendment").option("--claims <ids...>", "artifact IDs", []).option("--amends <ids...>", "artifact IDs this amends", []).action(
|
|
1381
|
+
async (slug, name, o) => process.exit(await newSlice(root(), slug, name, o))
|
|
1382
|
+
);
|
|
1383
|
+
n.command("journeys <slug>").description(
|
|
1384
|
+
"regenerate the journey tier from the flows, one fixme per reachable path"
|
|
1385
|
+
).action(async (slug) => process.exit(await newJourneys(root(), slug)));
|
|
1386
|
+
cli.command("accept [ids...]").description(
|
|
1387
|
+
"Re-stamp artifacts after the verification command passes in this same run."
|
|
1388
|
+
).option("--slice <path>", "stamp every claim of one slice").option("--clarification <note>", "record that the change was wording, not behaviour").option("--command <cmd>", "override the configured verification command").action(
|
|
1389
|
+
async (ids, o) => process.exit(await accept(root(), { ids, ...o }))
|
|
1390
|
+
);
|
|
1391
|
+
cli.command("sync").description("Re-render agent context for the configured harnesses.").action(async () => {
|
|
1392
|
+
const ctx = await loadContext(root());
|
|
1393
|
+
const w = sync(root(), ctx.config);
|
|
1394
|
+
for (const f of w.written)
|
|
1395
|
+
if (f.action !== "kept") console.log(` ${f.action.padEnd(8)}${f.path}`);
|
|
1396
|
+
});
|
|
1397
|
+
cli.command("doctor").description("Verify the install and say what each harness can and cannot enforce.").action(async () => process.exit(await doctor(root())));
|
|
1398
|
+
cli.command("board [feature]").description("Start the dev server and print the board URL.").action(async (feature) => process.exit(await board(root(), feature)));
|
|
1399
|
+
return cli;
|
|
1400
|
+
}
|
|
1401
|
+
async function run(argv) {
|
|
1402
|
+
try {
|
|
1403
|
+
await program().parseAsync(argv);
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
const err = e;
|
|
1406
|
+
if (err.code === "commander.helpDisplayed" || err.code === "commander.version" || err.code === "commander.help")
|
|
1407
|
+
return;
|
|
1408
|
+
console.error(err.message ?? String(e));
|
|
1409
|
+
process.exit(1);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
export {
|
|
1413
|
+
accept,
|
|
1414
|
+
check,
|
|
1415
|
+
detectFramework,
|
|
1416
|
+
detectHarnesses,
|
|
1417
|
+
doctor,
|
|
1418
|
+
init,
|
|
1419
|
+
loadContext,
|
|
1420
|
+
newFeature,
|
|
1421
|
+
newJourneys,
|
|
1422
|
+
newRule,
|
|
1423
|
+
newSlice,
|
|
1424
|
+
newState,
|
|
1425
|
+
program,
|
|
1426
|
+
run,
|
|
1427
|
+
status,
|
|
1428
|
+
sync
|
|
1429
|
+
};
|