ccqa 0.5.1 → 0.6.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 +6 -1
- package/dist/bin/ccqa.mjs +643 -34
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ ccqa run tasks/create-and-complete --drift --format github
|
|
|
78
78
|
| Assertion helper functions | [Assertions](./docs/assertions.md) |
|
|
79
79
|
| Auto-fix failing tests | [Auto-fix](./docs/auto-fix.md) |
|
|
80
80
|
| Detect spec/code drift in CI | [Drift](./docs/drift.md) |
|
|
81
|
+
| Inventory existing test coverage | [Perspectives](./docs/perspectives.md) |
|
|
81
82
|
|
|
82
83
|
## Commands
|
|
83
84
|
|
|
@@ -87,9 +88,10 @@ ccqa trace <feature/spec> Record browser actions for a spec (inlines an
|
|
|
87
88
|
ccqa generate <feature/spec> Generate test script from recorded actions
|
|
88
89
|
ccqa run [feature/spec] Execute generated test scripts (add --drift to analyze failures)
|
|
89
90
|
ccqa drift [feature/spec] Standalone spec ↔ codebase drift audit (for scheduled jobs)
|
|
91
|
+
ccqa perspectives Inventory existing test coverage into .ccqa/perspectives.yaml
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
All Claude-driven commands accept `-m, --model <name>` (alias `sonnet` | `opus` | `haiku`, or a full model ID). The flag overrides `CCQA_MODEL`; when both are unset, the Claude Code CLI default is used. Interactive commands authenticate via your local Claude Code login; commands that talk to Claude in CI (`ccqa run --drift`, `ccqa drift`) additionally honor `ANTHROPIC_API_KEY`.
|
|
94
|
+
All Claude-driven commands accept `-m, --model <name>` (alias `sonnet` | `opus` | `haiku`, or a full model ID). The flag overrides `CCQA_MODEL`; when both are unset, the Claude Code CLI default is used. They also accept `--language <bcp47>` (e.g. `ja`, `en`) to set the language of human-readable output; the default `auto` follows the language of the spec/codebase. Interactive commands authenticate via your local Claude Code login; commands that talk to Claude in CI (`ccqa run --drift`, `ccqa drift`) additionally honor `ANTHROPIC_API_KEY`.
|
|
93
95
|
|
|
94
96
|
`<feature/spec>` is a 2-segment alias for the on-disk path `.ccqa/features/<feature>/test-cases/<spec>/`.
|
|
95
97
|
|
|
@@ -97,11 +99,14 @@ All Claude-driven commands accept `-m, --model <name>` (alias `sonnet` | `opus`
|
|
|
97
99
|
|
|
98
100
|
```
|
|
99
101
|
.ccqa/
|
|
102
|
+
perspectives.yaml # Inventory of existing coverage (machine-readable, canonical)
|
|
103
|
+
perspectives.md # Category index, regenerated from the YAML
|
|
100
104
|
blocks/
|
|
101
105
|
login/
|
|
102
106
|
spec.yaml # Reusable block (params + steps)
|
|
103
107
|
features/
|
|
104
108
|
tasks/
|
|
109
|
+
perspectives.md # Per-category detail tables (one per case)
|
|
105
110
|
test-cases/
|
|
106
111
|
create-and-complete/
|
|
107
112
|
spec.yaml # Test definition
|
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -562,6 +562,71 @@ function isParamRequired(param) {
|
|
|
562
562
|
return param.required !== false;
|
|
563
563
|
}
|
|
564
564
|
//#endregion
|
|
565
|
+
//#region src/spec/perspectives-schema.ts
|
|
566
|
+
/**
|
|
567
|
+
* `perspectives.yaml` is an inventory of the test coverage that already
|
|
568
|
+
* exists under `.ccqa/` — the ccqa equivalent of a hand-kept QA spreadsheet,
|
|
569
|
+
* but scoped deliberately to *facts about what is tested today*.
|
|
570
|
+
*
|
|
571
|
+
* It intentionally does NOT carry severity / importance / priority. Deciding
|
|
572
|
+
* "how badly does it hurt the customer if this breaks" is a human + PdM
|
|
573
|
+
* decision, not something ccqa should author or silently overwrite. Keeping
|
|
574
|
+
* those columns out of the schema (and `.strict()` rejecting them) makes the
|
|
575
|
+
* boundary explicit: perspectives is a factual stock-take, severity lives
|
|
576
|
+
* wherever the team decides on it.
|
|
577
|
+
*
|
|
578
|
+
* It also does NOT attempt code-vs-test gap analysis (listing untested
|
|
579
|
+
* areas). A flat dump of "things in code with no test" is noise without
|
|
580
|
+
* prioritisation; that is a separate, later concern.
|
|
581
|
+
*/
|
|
582
|
+
/**
|
|
583
|
+
* Whether the spec has been traced / generated. Both are derived mechanically
|
|
584
|
+
* by the CLI from on-disk artifacts (actions.json / test.spec.ts), never
|
|
585
|
+
* written by Claude — these are facts and must not drift.
|
|
586
|
+
*/
|
|
587
|
+
const PerspectiveStatusSchema = z.object({
|
|
588
|
+
traced: z.boolean(),
|
|
589
|
+
generated: z.boolean()
|
|
590
|
+
}).strict();
|
|
591
|
+
/**
|
|
592
|
+
* One test case in the inventory.
|
|
593
|
+
*
|
|
594
|
+
* - `title` / `relatedPaths` are transcribed verbatim from the spec.yaml.
|
|
595
|
+
* - `status` is mechanically derived (see PerspectiveStatusSchema).
|
|
596
|
+
* - `summary` is a 1–2 sentence description of *what the spec verifies*,
|
|
597
|
+
* derived from its steps by Claude.
|
|
598
|
+
* - `startScreen` / `testCondition` / `preconditions` mirror the columns a
|
|
599
|
+
* hand-kept QA table carries. They are Claude-derived from the spec's
|
|
600
|
+
* steps (the opening screen, the state the test assumes, and the setup
|
|
601
|
+
* prerequisites such as which role logs in). Optional: a spec may not
|
|
602
|
+
* express all of them.
|
|
603
|
+
* - `note` is a human-only field. Regenerating perspectives preserves it.
|
|
604
|
+
*
|
|
605
|
+
* The detailed test procedure and expected results are deliberately NOT
|
|
606
|
+
* duplicated here — the spec.yaml steps are the single source of truth for
|
|
607
|
+
* those. The Markdown view links back to the spec instead of restating them.
|
|
608
|
+
*/
|
|
609
|
+
const PerspectiveSpecSchema = z.object({
|
|
610
|
+
specName: z.string().min(1),
|
|
611
|
+
title: z.string().min(1),
|
|
612
|
+
summary: z.string(),
|
|
613
|
+
startScreen: z.string().optional(),
|
|
614
|
+
testCondition: z.string().optional(),
|
|
615
|
+
preconditions: z.array(z.string().min(1)).optional(),
|
|
616
|
+
relatedPaths: z.array(z.string().min(1)).optional(),
|
|
617
|
+
status: PerspectiveStatusSchema,
|
|
618
|
+
note: z.string().optional()
|
|
619
|
+
}).strict();
|
|
620
|
+
const PerspectiveFeatureSchema = z.object({
|
|
621
|
+
featureName: z.string().min(1),
|
|
622
|
+
specs: z.array(PerspectiveSpecSchema)
|
|
623
|
+
}).strict();
|
|
624
|
+
/** Top-level perspectives schema. `.strict()` rejects any unknown key. */
|
|
625
|
+
const PerspectivesSchema = z.object({
|
|
626
|
+
generatedAt: z.string().optional(),
|
|
627
|
+
features: z.array(PerspectiveFeatureSchema)
|
|
628
|
+
}).strict();
|
|
629
|
+
//#endregion
|
|
565
630
|
//#region src/types.ts
|
|
566
631
|
const RouteStepSchema = z.object({
|
|
567
632
|
title: z.string(),
|
|
@@ -633,7 +698,7 @@ const DraftIssueSchema = z.object({
|
|
|
633
698
|
]),
|
|
634
699
|
stepId: z.string().nullable(),
|
|
635
700
|
message: z.string(),
|
|
636
|
-
detail: z.string().
|
|
701
|
+
detail: z.string().nullish()
|
|
637
702
|
});
|
|
638
703
|
const DraftReportSchema = z.object({
|
|
639
704
|
issues: z.array(DraftIssueSchema),
|
|
@@ -1205,6 +1270,8 @@ function collectIncludedBlockNames(spec) {
|
|
|
1205
1270
|
//#region src/store/index.ts
|
|
1206
1271
|
const CCQA_DIR = ".ccqa";
|
|
1207
1272
|
const SPEC_FILE = "spec.yaml";
|
|
1273
|
+
const PERSPECTIVES_FILE = "perspectives.yaml";
|
|
1274
|
+
const PERSPECTIVES_MD_FILE = "perspectives.md";
|
|
1208
1275
|
function getCcqaDir(cwd = process.cwd()) {
|
|
1209
1276
|
return join(cwd, CCQA_DIR);
|
|
1210
1277
|
}
|
|
@@ -1250,6 +1317,56 @@ async function saveSpecFile(featureName, specName, content, cwd) {
|
|
|
1250
1317
|
await writeFile(specPath, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
1251
1318
|
return specPath;
|
|
1252
1319
|
}
|
|
1320
|
+
/** Absolute path to the single repo-wide `.ccqa/perspectives.yaml`. */
|
|
1321
|
+
function getPerspectivesPath(cwd) {
|
|
1322
|
+
return join(getCcqaDir(cwd), PERSPECTIVES_FILE);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Read `.ccqa/perspectives.yaml` raw. Returns null when the file does not
|
|
1326
|
+
* exist (first-ever generation) so callers can treat it as optional.
|
|
1327
|
+
*/
|
|
1328
|
+
async function tryReadPerspectives(cwd) {
|
|
1329
|
+
return readFile(getPerspectivesPath(cwd), "utf-8").catch(() => null);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Write `.ccqa/perspectives.yaml`. Mirrors `saveSpecFile`: ensures the
|
|
1333
|
+
* directory exists and the content ends in a trailing newline.
|
|
1334
|
+
*/
|
|
1335
|
+
async function savePerspectives(content, cwd) {
|
|
1336
|
+
await mkdir(getCcqaDir(cwd), { recursive: true });
|
|
1337
|
+
const path = getPerspectivesPath(cwd);
|
|
1338
|
+
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
1339
|
+
return path;
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Human-readable Markdown companion to perspectives.yaml. The `.yaml` is the
|
|
1343
|
+
* machine-readable source of truth; the `.md` is a rendered view for review.
|
|
1344
|
+
*/
|
|
1345
|
+
function getPerspectivesMarkdownPath(cwd) {
|
|
1346
|
+
return join(getCcqaDir(cwd), PERSPECTIVES_MD_FILE);
|
|
1347
|
+
}
|
|
1348
|
+
async function savePerspectivesMarkdown(content, cwd) {
|
|
1349
|
+
await mkdir(getCcqaDir(cwd), { recursive: true });
|
|
1350
|
+
const path = getPerspectivesMarkdownPath(cwd);
|
|
1351
|
+
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
1352
|
+
return path;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Per-category detail view: `.ccqa/features/<feature>/perspectives.md`. The
|
|
1356
|
+
* root `perspectives.md` is a thin category index that links here; this file
|
|
1357
|
+
* carries the full per-case tables for one feature. The feature dir already
|
|
1358
|
+
* exists (it holds the test cases), but `mkdir -p` keeps this safe when called
|
|
1359
|
+
* in isolation.
|
|
1360
|
+
*/
|
|
1361
|
+
function getFeaturePerspectivesMarkdownPath(featureName, cwd) {
|
|
1362
|
+
return join(getFeatureDir(featureName, cwd), PERSPECTIVES_MD_FILE);
|
|
1363
|
+
}
|
|
1364
|
+
async function saveFeaturePerspectivesMarkdown(featureName, content, cwd) {
|
|
1365
|
+
await mkdir(getFeatureDir(featureName, cwd), { recursive: true });
|
|
1366
|
+
const path = getFeaturePerspectivesMarkdownPath(featureName, cwd);
|
|
1367
|
+
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
1368
|
+
return path;
|
|
1369
|
+
}
|
|
1253
1370
|
/**
|
|
1254
1371
|
* Replace (or insert) the `relatedPaths` key in the spec. Preserves every
|
|
1255
1372
|
* other top-level field and the entire steps array. Returns the absolute
|
|
@@ -2188,16 +2305,60 @@ function formatUnstableDrop(drop) {
|
|
|
2188
2305
|
return `${`${action.command}${action.assertType ? " " + action.assertType : ""}`}: contains unstable literal (${ids}) — ${samples}`;
|
|
2189
2306
|
}
|
|
2190
2307
|
//#endregion
|
|
2308
|
+
//#region src/prompts/language.ts
|
|
2309
|
+
/**
|
|
2310
|
+
* Shared language handling for every Claude-driven command. Each command
|
|
2311
|
+
* writes some human-readable text (drift findings, trace observations, draft
|
|
2312
|
+
* prose, diagnose hints, perspectives summaries), so the language policy is a
|
|
2313
|
+
* single cross-cutting concern rather than per-command logic.
|
|
2314
|
+
*
|
|
2315
|
+
* The value is a BCP-47 tag (e.g. "ja", "en") or the sentinel "auto". With
|
|
2316
|
+
* "auto" the model follows the language of the material it is given — Japanese
|
|
2317
|
+
* specs/codebase yield Japanese output — and `languageDirective` returns an
|
|
2318
|
+
* empty string so prompts stay byte-identical to the no-flag baseline.
|
|
2319
|
+
*/
|
|
2320
|
+
const DEFAULT_LANGUAGE = "auto";
|
|
2321
|
+
/**
|
|
2322
|
+
* The instruction appended to a command's system prompt. Empty for "auto"
|
|
2323
|
+
* (and undefined / blank), so the model keeps its natural material-following
|
|
2324
|
+
* behaviour; otherwise it pins every human-readable field to the given tag.
|
|
2325
|
+
*/
|
|
2326
|
+
function languageDirective(language) {
|
|
2327
|
+
const lang = (language ?? "auto").trim();
|
|
2328
|
+
if (lang === "" || lang === "auto") return "";
|
|
2329
|
+
return `\n\nIMPORTANT: Write every human-readable field, message, and explanation in **${lang}** (BCP-47 language tag), regardless of the language of the spec or codebase.`;
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Whether the CLI's own interactive prompts (the strings ccqa prints itself,
|
|
2333
|
+
* not the model's output) should be Japanese. Only an explicit Japanese tag
|
|
2334
|
+
* (`ja`, `ja-JP`, …) opts in; `auto` (the default) and every other tag keep
|
|
2335
|
+
* the English prompts, so an English user running with no flag is unaffected.
|
|
2336
|
+
*/
|
|
2337
|
+
function useJapanesePrompts(language) {
|
|
2338
|
+
return /^ja\b/i.test((language ?? "").trim());
|
|
2339
|
+
}
|
|
2340
|
+
//#endregion
|
|
2341
|
+
//#region src/cli/options.ts
|
|
2342
|
+
/**
|
|
2343
|
+
* Shared `--language` flag. Every Claude-driven command writes some
|
|
2344
|
+
* human-readable text, so language is a cross-cutting concern handled the same
|
|
2345
|
+
* way everywhere — much like `--model`. The value is a BCP-47 tag (e.g. "ja",
|
|
2346
|
+
* "en") or "auto" (default), which follows the language of the material.
|
|
2347
|
+
*/
|
|
2348
|
+
function addLanguageOption(command) {
|
|
2349
|
+
return command.option("--language <bcp47>", "Language for human-readable output (e.g. 'en', 'ja'). Default 'auto' follows the language of the spec/codebase.", DEFAULT_LANGUAGE);
|
|
2350
|
+
}
|
|
2351
|
+
//#endregion
|
|
2191
2352
|
//#region src/cli/trace.ts
|
|
2192
2353
|
const VALIDATION_MODES = ["lenient", "strict"];
|
|
2193
|
-
const traceCommand = new Command("trace").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Run agent-browser, verify assertions, and record structured actions").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions with a warning but keeps them; 'strict' drops them from actions.json.", (raw) => {
|
|
2354
|
+
const traceCommand = addLanguageOption(new Command("trace").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Run agent-browser, verify assertions, and record structured actions").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions with a warning but keeps them; 'strict' drops them from actions.json.", (raw) => {
|
|
2194
2355
|
if (VALIDATION_MODES.includes(raw)) return raw;
|
|
2195
2356
|
throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
|
|
2196
|
-
}, "lenient").action(async (specPath, opts) => {
|
|
2357
|
+
}, "lenient")).action(async (specPath, opts) => {
|
|
2197
2358
|
const { featureName, specName } = parseSpecPath(specPath);
|
|
2198
|
-
await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient");
|
|
2359
|
+
await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", opts.language);
|
|
2199
2360
|
});
|
|
2200
|
-
async function runTrace(featureName, specName, model, validationMode = "lenient") {
|
|
2361
|
+
async function runTrace(featureName, specName, model, validationMode = "lenient", language) {
|
|
2201
2362
|
header("trace", `${featureName}/${specName}`);
|
|
2202
2363
|
try {
|
|
2203
2364
|
meta("agent-browser", assertAgentBrowserAvailable());
|
|
@@ -2228,7 +2389,7 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
2228
2389
|
});
|
|
2229
2390
|
const userPrompt = await loadTraceUserPrompt();
|
|
2230
2391
|
if (userPrompt !== null) meta("user-prompt", ".ccqa/prompts/trace.user.md");
|
|
2231
|
-
const systemPrompt = userPrompt === null ? baseSystemPrompt : `${baseSystemPrompt}\n## Project-specific guidance\n\n${userPrompt}\n
|
|
2392
|
+
const systemPrompt = (userPrompt === null ? baseSystemPrompt : `${baseSystemPrompt}\n## Project-specific guidance\n\n${userPrompt}\n`) + languageDirective(language);
|
|
2232
2393
|
const prompt = buildTracePrompt(spec.title);
|
|
2233
2394
|
info("Running agent-browser session...");
|
|
2234
2395
|
blank();
|
|
@@ -3219,16 +3380,24 @@ function previewDiff(before, after) {
|
|
|
3219
3380
|
//#endregion
|
|
3220
3381
|
//#region src/diagnose/prompt.ts
|
|
3221
3382
|
function buildDiagnosePrompt(input) {
|
|
3222
|
-
const { script, specYaml, actions, failureLog, pageSnapshot, outputLanguage = "
|
|
3383
|
+
const { script, specYaml, actions, failureLog, pageSnapshot, outputLanguage = "auto" } = input;
|
|
3223
3384
|
const numbered = script.split("\n").map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
3385
|
+
const actionsSummary = actions.map((a, i) => {
|
|
3386
|
+
const parts = [`${i + 1}. ${a.command}`];
|
|
3387
|
+
if (a.assertType) parts.push(`assertType="${a.assertType}"`);
|
|
3388
|
+
if (a.selector) parts.push(`selector="${a.selector}"`);
|
|
3389
|
+
if (a.value) parts.push(`value="${a.value}"`);
|
|
3390
|
+
if (a.observation) parts.push(`→ ${a.observation}`);
|
|
3391
|
+
return parts.join(" ");
|
|
3392
|
+
}).join("\n");
|
|
3224
3393
|
return `You are diagnosing a failing E2E test. The test was generated from a recorded trace of the original interaction. Compare the failing run against the original spec and recorded actions to determine WHY the test failed and what the right fix is.
|
|
3225
3394
|
|
|
3226
|
-
|
|
3395
|
+
${outputLanguage === "auto" ? "" : `## Output language
|
|
3227
3396
|
|
|
3228
3397
|
Write all human-readable fields (\`reasoning\`, \`reason\`) in **${outputLanguage}** (BCP-47 tag).
|
|
3229
3398
|
Selectors, file paths, identifiers, code, type names (TIMING_ISSUE, etc.), JSON keys, and quoted strings stay verbatim regardless of language.
|
|
3230
3399
|
|
|
3231
|
-
## You have read-only filesystem tools
|
|
3400
|
+
`}## You have read-only filesystem tools
|
|
3232
3401
|
|
|
3233
3402
|
You can call \`Grep\`, \`Glob\`, and \`Read\` against the current repository before producing the JSON.
|
|
3234
3403
|
|
|
@@ -3317,14 +3486,7 @@ Pick exactly ONE category. The output JSON must follow the shape for that catego
|
|
|
3317
3486
|
${specYaml}
|
|
3318
3487
|
|
|
3319
3488
|
## Recorded Actions (actions.json summary)
|
|
3320
|
-
${
|
|
3321
|
-
const parts = [`${i + 1}. ${a.command}`];
|
|
3322
|
-
if (a.assertType) parts.push(`assertType="${a.assertType}"`);
|
|
3323
|
-
if (a.selector) parts.push(`selector="${a.selector}"`);
|
|
3324
|
-
if (a.value) parts.push(`value="${a.value}"`);
|
|
3325
|
-
if (a.observation) parts.push(`→ ${a.observation}`);
|
|
3326
|
-
return parts.join(" ");
|
|
3327
|
-
}).join("\n")}
|
|
3489
|
+
${actionsSummary}
|
|
3328
3490
|
|
|
3329
3491
|
## Test Script (with line numbers)
|
|
3330
3492
|
${numbered}
|
|
@@ -3901,11 +4063,11 @@ function resolveMode(opts) {
|
|
|
3901
4063
|
}
|
|
3902
4064
|
//#endregion
|
|
3903
4065
|
//#region src/cli/generate.ts
|
|
3904
|
-
const generateCommand = new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate agent-browser test script from recorded trace actions. test.spec.ts is regenerated from actions.json on every run; pass --force to overwrite manual edits.").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--auto", "Apply auto-fixes without confirmation regardless of confidence (CI use)").option("--no-interactive", "Never prompt; only auto-apply when confidence is high, otherwise give up").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("
|
|
4066
|
+
const generateCommand = addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate agent-browser test script from recorded trace actions. test.spec.ts is regenerated from actions.json on every run; pass --force to overwrite manual edits.").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--auto", "Apply auto-fixes without confirmation regardless of confidence (CI use)").option("--no-interactive", "Never prompt; only auto-apply when confidence is high, otherwise give up").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.")).action(async (specPath, opts) => {
|
|
3905
4067
|
const { featureName, specName } = parseSpecPath(specPath);
|
|
3906
4068
|
const mode = resolveMode(opts);
|
|
3907
4069
|
const useSnapshot = opts.snapshot !== false;
|
|
3908
|
-
await runGenerate(featureName, specName, parseInt(opts.maxRetries, 10), mode, opts.force ?? false, useSnapshot, opts.language ?? "
|
|
4070
|
+
await runGenerate(featureName, specName, parseInt(opts.maxRetries, 10), mode, opts.force ?? false, useSnapshot, opts.language ?? "auto", opts.model);
|
|
3909
4071
|
});
|
|
3910
4072
|
async function runGenerate(featureName, specName, maxRetries, mode, force, useSnapshot, outputLanguage, model) {
|
|
3911
4073
|
header("generate", `${featureName}/${specName}`);
|
|
@@ -4395,7 +4557,7 @@ const DEFAULT_CONCURRENCY$1 = 3;
|
|
|
4395
4557
|
* `cli/run` calls this with just the failing specs after vitest.
|
|
4396
4558
|
*/
|
|
4397
4559
|
async function analyzeDrift(input) {
|
|
4398
|
-
const { targets, cwd, blocks, concurrency = DEFAULT_CONCURRENCY$1, model, onSpecStart } = input;
|
|
4560
|
+
const { targets, cwd, blocks, concurrency = DEFAULT_CONCURRENCY$1, model, language, onSpecStart } = input;
|
|
4399
4561
|
const results = new Array(targets.length);
|
|
4400
4562
|
let cursor = 0;
|
|
4401
4563
|
const worker = async () => {
|
|
@@ -4407,7 +4569,8 @@ async function analyzeDrift(input) {
|
|
|
4407
4569
|
results[idx] = await checkSpec(target, {
|
|
4408
4570
|
cwd,
|
|
4409
4571
|
blocks,
|
|
4410
|
-
model
|
|
4572
|
+
model,
|
|
4573
|
+
language
|
|
4411
4574
|
});
|
|
4412
4575
|
}
|
|
4413
4576
|
};
|
|
@@ -4426,7 +4589,7 @@ async function checkSpec(target, opts) {
|
|
|
4426
4589
|
};
|
|
4427
4590
|
const { result, isError } = await invokeClaudeStreaming({
|
|
4428
4591
|
prompt: buildDriftUserPrompt(existing),
|
|
4429
|
-
systemPrompt: buildDriftSystemPrompt(opts.blocks),
|
|
4592
|
+
systemPrompt: buildDriftSystemPrompt(opts.blocks) + languageDirective(opts.language),
|
|
4430
4593
|
allowedTools: [
|
|
4431
4594
|
"Read",
|
|
4432
4595
|
"Grep",
|
|
@@ -4634,7 +4797,7 @@ async function resolveVitestConfig() {
|
|
|
4634
4797
|
return bundledVitestConfigPath();
|
|
4635
4798
|
}
|
|
4636
4799
|
}
|
|
4637
|
-
const runCommand = new Command("run").argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all").description("Run generated agent-browser test scripts. Pass --drift to invoke a Claude-driven drift analysis on each failing spec (skipped silently when no test fails). Requires ANTHROPIC_API_KEY or a local Claude login.").option("--drift", "On vitest failure, run drift analysis on the failing specs").option("--drift-strict", "Treat drift ERROR findings as a run failure (exit 1 even if vitest passed). Implies --drift.").option("--format <fmt>", "Output format for the drift block: text | json | github", "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Used by --drift only. Overrides CCQA_MODEL.").action(async (target, opts) => {
|
|
4800
|
+
const runCommand = addLanguageOption(new Command("run").argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all").description("Run generated agent-browser test scripts. Pass --drift to invoke a Claude-driven drift analysis on each failing spec (skipped silently when no test fails). Requires ANTHROPIC_API_KEY or a local Claude login.").option("--drift", "On vitest failure, run drift analysis on the failing specs").option("--drift-strict", "Treat drift ERROR findings as a run failure (exit 1 even if vitest passed). Implies --drift.").option("--format <fmt>", "Output format for the drift block: text | json | github", "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Used by --drift only. Overrides CCQA_MODEL.")).action(async (target, opts) => {
|
|
4638
4801
|
await runTests(target, opts);
|
|
4639
4802
|
});
|
|
4640
4803
|
async function runTests(target, opts) {
|
|
@@ -4754,6 +4917,7 @@ async function maybeRunDrift(summaries, opts, currentExitCode) {
|
|
|
4754
4917
|
blocks: await loadAvailableBlocks(cwd),
|
|
4755
4918
|
concurrency: Math.min(3, targets.length),
|
|
4756
4919
|
...opts.model ? { model: opts.model } : {},
|
|
4920
|
+
...opts.language ? { language: opts.language } : {},
|
|
4757
4921
|
onSpecStart: (t) => {
|
|
4758
4922
|
if (format === "text") info(`drift: checking ${t.featureName}/${t.specName}`);
|
|
4759
4923
|
}
|
|
@@ -4866,7 +5030,7 @@ async function resolveSpecs(target) {
|
|
|
4866
5030
|
//#endregion
|
|
4867
5031
|
//#region src/cli/draft.ts
|
|
4868
5032
|
const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
|
|
4869
|
-
const draftCommand = new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false).action(async (specPath, opts) => {
|
|
5033
|
+
const draftCommand = addLanguageOption(new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false)).action(async (specPath, opts) => {
|
|
4870
5034
|
await ensureCcqaDir();
|
|
4871
5035
|
let featureName;
|
|
4872
5036
|
let specName;
|
|
@@ -4882,6 +5046,7 @@ const draftCommand = new Command("draft").argument("[feature/spec]", "Optional s
|
|
|
4882
5046
|
});
|
|
4883
5047
|
async function runDraft(featureName, specName, opts, prefilledIntent) {
|
|
4884
5048
|
header("draft", `${featureName}/${specName}`);
|
|
5049
|
+
const ja = useJapanesePrompts(opts.language);
|
|
4885
5050
|
const oneShot = opts.instruction !== void 0;
|
|
4886
5051
|
let useIntentOnce = prefilledIntent !== null && !oneShot;
|
|
4887
5052
|
while (true) {
|
|
@@ -4892,7 +5057,7 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
|
|
|
4892
5057
|
else if (useIntentOnce && isFirstRun) {
|
|
4893
5058
|
userInput = prefilledIntent ?? "";
|
|
4894
5059
|
useIntentOnce = false;
|
|
4895
|
-
} else userInput = await prompt(isFirstRun ? "What do you want to test? > " : "How would you like to refine? (empty = re-validate) > ");
|
|
5060
|
+
} else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
|
|
4896
5061
|
if (isFirstRun && !userInput.trim()) {
|
|
4897
5062
|
error("intent required for the first draft (no spec exists yet)");
|
|
4898
5063
|
process.exit(1);
|
|
@@ -4902,11 +5067,12 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
|
|
|
4902
5067
|
specName,
|
|
4903
5068
|
existing,
|
|
4904
5069
|
userInput: userInput.trim(),
|
|
4905
|
-
autoApply: opts.apply === true
|
|
5070
|
+
autoApply: opts.apply === true,
|
|
5071
|
+
language: opts.language
|
|
4906
5072
|
});
|
|
4907
5073
|
if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
|
|
4908
5074
|
blank();
|
|
4909
|
-
if (/^y/i.test(await prompt("Are you done with this draft? [y/N] "))) {
|
|
5075
|
+
if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
|
|
4910
5076
|
info("draft session complete.");
|
|
4911
5077
|
hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
|
|
4912
5078
|
process.exit(0);
|
|
@@ -4914,9 +5080,9 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
|
|
|
4914
5080
|
}
|
|
4915
5081
|
}
|
|
4916
5082
|
async function runOneTurn(input) {
|
|
4917
|
-
const { featureName, specName, existing, userInput, autoApply } = input;
|
|
5083
|
+
const { featureName, specName, existing, userInput, autoApply, language } = input;
|
|
4918
5084
|
const isFirstRun = existing === null;
|
|
4919
|
-
const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks());
|
|
5085
|
+
const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
|
|
4920
5086
|
const userPrompt = buildDraftPrompt({
|
|
4921
5087
|
mode: isFirstRun ? "create" : "refine",
|
|
4922
5088
|
existing: existing ?? "",
|
|
@@ -4979,7 +5145,7 @@ async function runOneTurn(input) {
|
|
|
4979
5145
|
info("--- proposed changes ---");
|
|
4980
5146
|
printUnifiedDiff(original, report.patch);
|
|
4981
5147
|
blank();
|
|
4982
|
-
if (!(autoApply ? true : /^y/i.test(await prompt("Apply this patch? [y/N] ")))) {
|
|
5148
|
+
if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
|
|
4983
5149
|
info("aborted — no changes applied.");
|
|
4984
5150
|
return {
|
|
4985
5151
|
hasError,
|
|
@@ -5071,8 +5237,9 @@ function writeFinding(issue) {
|
|
|
5071
5237
|
if (issue.detail) process.stdout.write(` └ ${issue.detail.replace(/\n/g, "\n ")}\n`);
|
|
5072
5238
|
}
|
|
5073
5239
|
async function proposeNaming(opts) {
|
|
5240
|
+
const ja = useJapanesePrompts(opts.language);
|
|
5074
5241
|
const oneShot = opts.instruction !== void 0;
|
|
5075
|
-
const intent = oneShot ? opts.instruction ?? "" : await prompt("What do you want to test? > ");
|
|
5242
|
+
const intent = oneShot ? opts.instruction ?? "" : await prompt(ja ? "何をテストしたいですか? > " : "What do you want to test? > ");
|
|
5076
5243
|
if (!intent.trim()) {
|
|
5077
5244
|
error("intent required to propose a feature/spec name");
|
|
5078
5245
|
process.exit(1);
|
|
@@ -5124,13 +5291,13 @@ async function proposeNaming(opts) {
|
|
|
5124
5291
|
naming: final,
|
|
5125
5292
|
intent: intent.trim()
|
|
5126
5293
|
};
|
|
5127
|
-
const answer = await prompt(
|
|
5294
|
+
const answer = await prompt(ja ? "この名前を使いますか? [y/N/edit] > " : "Use this name? [y/N/edit] > ");
|
|
5128
5295
|
if (/^y/i.test(answer)) return {
|
|
5129
5296
|
naming: final,
|
|
5130
5297
|
intent: intent.trim()
|
|
5131
5298
|
};
|
|
5132
5299
|
if (/^e/i.test(answer)) {
|
|
5133
|
-
const manual = await prompt("Enter feature/spec (e.g. tasks/create-and-complete) > ");
|
|
5300
|
+
const manual = await prompt(ja ? "feature/spec を入力 (例 tasks/create-and-complete) > " : "Enter feature/spec (e.g. tasks/create-and-complete) > ");
|
|
5134
5301
|
const parts = manual.split("/");
|
|
5135
5302
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
5136
5303
|
error(`invalid spec path: "${manual}". Expected "<feature>/<spec>"`);
|
|
@@ -5503,7 +5670,7 @@ Return the spec keys that might be affected by any of the new files. Conservativ
|
|
|
5503
5670
|
//#endregion
|
|
5504
5671
|
//#region src/cli/drift.ts
|
|
5505
5672
|
const DEFAULT_CONCURRENCY = 3;
|
|
5506
|
-
const driftCommand = new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Check whether each spec.yaml is still in sync with the current codebase (CI-friendly, no patches applied).").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.").action(async (specPath, opts) => {
|
|
5673
|
+
const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Check whether each spec.yaml is still in sync with the current codebase (CI-friendly, no patches applied).").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.")).action(async (specPath, opts) => {
|
|
5507
5674
|
const format = parseFormat(opts.format);
|
|
5508
5675
|
const threshold = parseSeverity(opts.severity);
|
|
5509
5676
|
const concurrency = parseConcurrency(opts.concurrency);
|
|
@@ -5538,6 +5705,7 @@ const driftCommand = new Command("drift").argument("[feature/spec]", "Optional s
|
|
|
5538
5705
|
blocks,
|
|
5539
5706
|
concurrency,
|
|
5540
5707
|
...opts.model ? { model: opts.model } : {},
|
|
5708
|
+
...opts.language ? { language: opts.language } : {},
|
|
5541
5709
|
onSpecStart: (t) => {
|
|
5542
5710
|
if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
|
|
5543
5711
|
}
|
|
@@ -5650,6 +5818,446 @@ function parseConcurrency(raw) {
|
|
|
5650
5818
|
return n;
|
|
5651
5819
|
}
|
|
5652
5820
|
//#endregion
|
|
5821
|
+
//#region src/prompts/perspectives.ts
|
|
5822
|
+
/**
|
|
5823
|
+
* Build the system prompt. By default the descriptive fields follow the
|
|
5824
|
+
* spec's own language (Japanese specs → Japanese fields). An explicit
|
|
5825
|
+
* `--language` is applied by the CLI via `languageDirective`, appended to
|
|
5826
|
+
* this prompt, so the language handling lives in one shared place.
|
|
5827
|
+
*/
|
|
5828
|
+
function buildPerspectivesSystemPrompt() {
|
|
5829
|
+
return `You produce a factual inventory of the E2E test coverage that already exists in a ccqa project.
|
|
5830
|
+
|
|
5831
|
+
Think of it as a QA coverage stock-take: for each existing test case, fill in a few short, neutral descriptive fields derived from its steps. Nothing more.
|
|
5832
|
+
|
|
5833
|
+
## Hard boundaries (do NOT cross)
|
|
5834
|
+
|
|
5835
|
+
- Do NOT assign severity, importance, priority, or risk. Whether a failure hurts the customer is a human + PdM decision; you are not authoring that here.
|
|
5836
|
+
- Do NOT do gap analysis. Do NOT list untested areas, missing coverage, or things the code has but the tests lack.
|
|
5837
|
+
- Do NOT evaluate whether the feature is good, complete, or correct.
|
|
5838
|
+
- Do NOT propose new test cases.
|
|
5839
|
+
- Do NOT restate the full step-by-step procedure or the per-step expected results — the spec.yaml is the source of truth for those and the inventory links to it.
|
|
5840
|
+
- Do NOT touch status, relatedPaths, feature names, or spec names — the CLI already fixed those.
|
|
5841
|
+
|
|
5842
|
+
## Fields to write (per spec)
|
|
5843
|
+
|
|
5844
|
+
- \`summary\`: 1–2 sentences, factual and neutral. What the test exercises and what it ultimately asserts, derived from the spec's \`steps\` (\`instruction\` / \`expected\`).
|
|
5845
|
+
- \`startScreen\`: the screen/URL the test first lands on after setup (e.g. "Dashboard (/dashboard)"). Derive from the first non-login \`instruction\`. Omit if genuinely unclear.
|
|
5846
|
+
- \`testCondition\`: the state/precondition the scenario assumes, phrased as a condition (e.g. "Logged in as an admin", "Unauthenticated user"). Omit if none.
|
|
5847
|
+
- \`preconditions\`: array of short setup prerequisites (e.g. which role logs in, required prior state). Derive from \`include: login\` params and the opening steps. Empty/omit if none.
|
|
5848
|
+
|
|
5849
|
+
## How to write
|
|
5850
|
+
|
|
5851
|
+
- Same language as the spec's title (if titles are Japanese, write these fields in Japanese).
|
|
5852
|
+
- Keep each field short. These are index entries, not the test itself.
|
|
5853
|
+
- You may use Read/Grep/Glob sparingly to clarify domain vocabulary, but the steps are the primary source. Do not over-explore.
|
|
5854
|
+
|
|
5855
|
+
## Output contract (STRICT)
|
|
5856
|
+
|
|
5857
|
+
Output exactly ONE fenced \`\`\`json code block, and nothing else outside it. No prose before or after.
|
|
5858
|
+
|
|
5859
|
+
Schema:
|
|
5860
|
+
|
|
5861
|
+
\`\`\`json
|
|
5862
|
+
{
|
|
5863
|
+
"summaries": [
|
|
5864
|
+
{
|
|
5865
|
+
"featureName": "<verbatim from input>",
|
|
5866
|
+
"specName": "<verbatim from input>",
|
|
5867
|
+
"summary": "<1–2 sentence factual description of what this test verifies>",
|
|
5868
|
+
"startScreen": "<opening screen/URL, or omit>",
|
|
5869
|
+
"testCondition": "<assumed state phrased as a condition, or omit>",
|
|
5870
|
+
"preconditions": ["<setup prerequisite>", "..."]
|
|
5871
|
+
}
|
|
5872
|
+
]
|
|
5873
|
+
}
|
|
5874
|
+
\`\`\`
|
|
5875
|
+
|
|
5876
|
+
Return one entry per spec given in the input. Echo featureName and specName verbatim so the CLI can match them. \`startScreen\`, \`testCondition\`, and \`preconditions\` are optional — omit a field (or use an empty array for preconditions) when the spec does not express it.
|
|
5877
|
+
`;
|
|
5878
|
+
}
|
|
5879
|
+
function buildPerspectivesPrompt(specs, instruction) {
|
|
5880
|
+
return `## Existing test cases to summarise
|
|
5881
|
+
|
|
5882
|
+
${specs.map((s) => `### ${s.featureName}/${s.specName}
|
|
5883
|
+
title: ${s.title}
|
|
5884
|
+
|
|
5885
|
+
\`\`\`yaml
|
|
5886
|
+
${s.specYaml.trimEnd()}
|
|
5887
|
+
\`\`\`
|
|
5888
|
+
`).join("\n")}
|
|
5889
|
+
${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
|
|
5890
|
+
|
|
5891
|
+
For each test case above, write a 1–2 sentence factual \`summary\` of what it verifies, derived from its steps. Return one entry per spec in the JSON contract. Do not assign severity, do gap analysis, or invent new cases.
|
|
5892
|
+
`;
|
|
5893
|
+
}
|
|
5894
|
+
//#endregion
|
|
5895
|
+
//#region src/cli/perspectives.ts
|
|
5896
|
+
const perspectivesCommand = addLanguageOption(new Command("perspectives").description("Generate/update .ccqa/perspectives.yaml — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("--apply", "Auto-apply without [y/N] confirmation", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID")).action(async (opts) => {
|
|
5897
|
+
await runPerspectives(opts);
|
|
5898
|
+
});
|
|
5899
|
+
async function runPerspectives(opts) {
|
|
5900
|
+
header("perspectives", ".ccqa/perspectives.yaml");
|
|
5901
|
+
await ensureCcqaDir();
|
|
5902
|
+
const skeleton = await buildSkeleton(await listFeatureTree());
|
|
5903
|
+
const allSpecs = skeleton.flatMap((f) => f.specs);
|
|
5904
|
+
if (allSpecs.length === 0) {
|
|
5905
|
+
info("no test cases found under .ccqa/features — nothing to inventory.");
|
|
5906
|
+
return;
|
|
5907
|
+
}
|
|
5908
|
+
const existingRaw = await tryReadPerspectives() ?? "";
|
|
5909
|
+
const noteMap = extractNotes(existingRaw);
|
|
5910
|
+
const specBodies = await loadSpecBodies(skeleton);
|
|
5911
|
+
meta("language", opts.language ?? "auto");
|
|
5912
|
+
info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
|
|
5913
|
+
const summaries = await requestSummaries(specBodies, opts);
|
|
5914
|
+
if (summaries === null) process.exit(1);
|
|
5915
|
+
const merged = mergePerspectives(skeleton, summaries, noteMap);
|
|
5916
|
+
let validated;
|
|
5917
|
+
try {
|
|
5918
|
+
validated = PerspectivesSchema.parse(merged);
|
|
5919
|
+
} catch (e) {
|
|
5920
|
+
error(`refused to write: assembled perspectives failed validation (${e.message})`);
|
|
5921
|
+
process.exit(1);
|
|
5922
|
+
}
|
|
5923
|
+
const next = stringify(validated, { lineWidth: 0 });
|
|
5924
|
+
if (withoutGeneratedAt(existingRaw) === withoutGeneratedAt(next)) {
|
|
5925
|
+
blank();
|
|
5926
|
+
info("perspectives already up to date — no changes.");
|
|
5927
|
+
return;
|
|
5928
|
+
}
|
|
5929
|
+
blank();
|
|
5930
|
+
info("--- proposed changes (perspectives.yaml) ---");
|
|
5931
|
+
printUnifiedDiff(existingRaw, next);
|
|
5932
|
+
blank();
|
|
5933
|
+
if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "perspectives.yaml + .md を書き込みますか? [y/N] " : "Write perspectives.yaml + .md? [y/N] ")))) {
|
|
5934
|
+
info("aborted — no changes written.");
|
|
5935
|
+
return;
|
|
5936
|
+
}
|
|
5937
|
+
meta("saved", await savePerspectives(next));
|
|
5938
|
+
const labels = labelsFor(opts.language);
|
|
5939
|
+
meta("saved", await savePerspectivesMarkdown(renderIndexMarkdown(validated, labels)));
|
|
5940
|
+
for (const feature of validated.features) meta("saved", await saveFeaturePerspectivesMarkdown(feature.featureName, renderFeatureMarkdown(feature, labels)));
|
|
5941
|
+
}
|
|
5942
|
+
/**
|
|
5943
|
+
* Turn the feature tree into the skeleton perspectives features: title +
|
|
5944
|
+
* relatedPaths transcribed from each spec, status derived mechanically from
|
|
5945
|
+
* on-disk artifacts. `summary` is left empty here; Claude fills it later.
|
|
5946
|
+
* Specs whose spec.yaml is missing or unparsable are skipped.
|
|
5947
|
+
*/
|
|
5948
|
+
async function buildSkeleton(tree) {
|
|
5949
|
+
return (await Promise.all(tree.map(async (feature) => {
|
|
5950
|
+
const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
|
|
5951
|
+
const spec = await readSpecMeta(feature.featureName, s.specName);
|
|
5952
|
+
const status = await deriveStatus(feature.featureName, s.specName);
|
|
5953
|
+
const entry = {
|
|
5954
|
+
specName: s.specName,
|
|
5955
|
+
title: spec.title,
|
|
5956
|
+
summary: "",
|
|
5957
|
+
status
|
|
5958
|
+
};
|
|
5959
|
+
if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
|
|
5960
|
+
return entry;
|
|
5961
|
+
}));
|
|
5962
|
+
return {
|
|
5963
|
+
featureName: feature.featureName,
|
|
5964
|
+
specs
|
|
5965
|
+
};
|
|
5966
|
+
}))).filter((f) => f.specs.length > 0).map((f) => ({
|
|
5967
|
+
featureName: f.featureName,
|
|
5968
|
+
specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
|
|
5969
|
+
})).sort((a, b) => a.featureName.localeCompare(b.featureName));
|
|
5970
|
+
}
|
|
5971
|
+
/**
|
|
5972
|
+
* `(featureName, specName)` → human note, parsed from an existing
|
|
5973
|
+
* perspectives.yaml. Notes are preserved across regeneration; everything
|
|
5974
|
+
* else (title, status, summary) is recomputed. Returns an empty map when the
|
|
5975
|
+
* input is empty or unparsable — note preservation is best-effort and never
|
|
5976
|
+
* blocks regeneration.
|
|
5977
|
+
*/
|
|
5978
|
+
function extractNotes(existingRaw) {
|
|
5979
|
+
const map = /* @__PURE__ */ new Map();
|
|
5980
|
+
if (!existingRaw.trim()) return map;
|
|
5981
|
+
let parsed;
|
|
5982
|
+
try {
|
|
5983
|
+
parsed = parse(existingRaw);
|
|
5984
|
+
} catch {
|
|
5985
|
+
return map;
|
|
5986
|
+
}
|
|
5987
|
+
const result = PerspectivesSchema.safeParse(parsed);
|
|
5988
|
+
if (!result.success) return map;
|
|
5989
|
+
for (const feature of result.data.features) for (const spec of feature.specs) if (spec.note !== void 0 && spec.note !== "") map.set(noteKey(feature.featureName, spec.specName), spec.note);
|
|
5990
|
+
return map;
|
|
5991
|
+
}
|
|
5992
|
+
/**
|
|
5993
|
+
* Merge the mechanical skeleton with Claude's summaries and the preserved
|
|
5994
|
+
* notes into the final perspectives object. Summaries are matched by
|
|
5995
|
+
* (featureName, specName); an unmatched spec keeps its empty summary.
|
|
5996
|
+
*/
|
|
5997
|
+
function mergePerspectives(skeleton, summaries, noteMap) {
|
|
5998
|
+
const summaryMap = /* @__PURE__ */ new Map();
|
|
5999
|
+
for (const s of summaries) summaryMap.set(noteKey(s.featureName, s.specName), s);
|
|
6000
|
+
const features = skeleton.map((feature) => ({
|
|
6001
|
+
featureName: feature.featureName,
|
|
6002
|
+
specs: feature.specs.map((spec) => {
|
|
6003
|
+
const key = noteKey(feature.featureName, spec.specName);
|
|
6004
|
+
const entry = summaryMap.get(key);
|
|
6005
|
+
const merged = {
|
|
6006
|
+
...spec,
|
|
6007
|
+
summary: entry?.summary ?? spec.summary
|
|
6008
|
+
};
|
|
6009
|
+
if (entry?.startScreen) merged.startScreen = entry.startScreen;
|
|
6010
|
+
if (entry?.testCondition) merged.testCondition = entry.testCondition;
|
|
6011
|
+
if (entry?.preconditions && entry.preconditions.length > 0) merged.preconditions = entry.preconditions;
|
|
6012
|
+
const note = noteMap.get(key);
|
|
6013
|
+
if (note !== void 0) merged.note = note;
|
|
6014
|
+
return merged;
|
|
6015
|
+
})
|
|
6016
|
+
}));
|
|
6017
|
+
return {
|
|
6018
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6019
|
+
features
|
|
6020
|
+
};
|
|
6021
|
+
}
|
|
6022
|
+
/**
|
|
6023
|
+
* Strip the top-level `generatedAt:` line so two serialised perspectives can
|
|
6024
|
+
* be compared for substantive equality without the always-fresh timestamp
|
|
6025
|
+
* defeating the "already up to date" check. Exported for unit testing.
|
|
6026
|
+
*/
|
|
6027
|
+
function withoutGeneratedAt(yamlText) {
|
|
6028
|
+
return yamlText.split("\n").filter((line) => !/^generatedAt:/.test(line)).join("\n").trim();
|
|
6029
|
+
}
|
|
6030
|
+
function noteKey(featureName, specName) {
|
|
6031
|
+
return `${featureName}/${specName}`;
|
|
6032
|
+
}
|
|
6033
|
+
async function readSpecMeta(featureName, specName) {
|
|
6034
|
+
const raw = await tryReadSpecFile(featureName, specName);
|
|
6035
|
+
if (raw === null) return { title: specName };
|
|
6036
|
+
try {
|
|
6037
|
+
const parsed = parse(raw);
|
|
6038
|
+
if (typeof parsed.title === "string" && parsed.title.length > 0) return { title: parsed.title };
|
|
6039
|
+
} catch {}
|
|
6040
|
+
return { title: specName };
|
|
6041
|
+
}
|
|
6042
|
+
async function deriveStatus(featureName, specName) {
|
|
6043
|
+
return {
|
|
6044
|
+
traced: await stat(join(getSpecDir(featureName, specName), "actions.json")).then(() => true).catch(() => false),
|
|
6045
|
+
generated: await getTestScript(featureName, specName) !== null
|
|
6046
|
+
};
|
|
6047
|
+
}
|
|
6048
|
+
async function loadSpecBodies(skeleton) {
|
|
6049
|
+
return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
|
|
6050
|
+
const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
|
|
6051
|
+
return {
|
|
6052
|
+
featureName: feature.featureName,
|
|
6053
|
+
specName: spec.specName,
|
|
6054
|
+
title: spec.title,
|
|
6055
|
+
specYaml
|
|
6056
|
+
};
|
|
6057
|
+
})));
|
|
6058
|
+
}
|
|
6059
|
+
async function requestSummaries(specs, opts) {
|
|
6060
|
+
const toolCounts = {};
|
|
6061
|
+
const startedAt = Date.now();
|
|
6062
|
+
const { result, isError } = await invokeClaudeStreaming({
|
|
6063
|
+
prompt: buildPerspectivesPrompt(specs, opts.instruction),
|
|
6064
|
+
systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
|
|
6065
|
+
allowedTools: [
|
|
6066
|
+
"Read",
|
|
6067
|
+
"Grep",
|
|
6068
|
+
"Glob"
|
|
6069
|
+
],
|
|
6070
|
+
silenceBashLog: true,
|
|
6071
|
+
...opts.model ? { model: opts.model } : {}
|
|
6072
|
+
}, (msg) => {
|
|
6073
|
+
if (msg.type !== "assistant") return;
|
|
6074
|
+
for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
|
|
6075
|
+
});
|
|
6076
|
+
process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
|
|
6077
|
+
if (isError) {
|
|
6078
|
+
error("Claude returned an error result");
|
|
6079
|
+
return null;
|
|
6080
|
+
}
|
|
6081
|
+
const json = extractJsonBlock(result);
|
|
6082
|
+
if (!json) {
|
|
6083
|
+
error("Claude did not return a json block");
|
|
6084
|
+
return null;
|
|
6085
|
+
}
|
|
6086
|
+
return parseSummaries(json);
|
|
6087
|
+
}
|
|
6088
|
+
/**
|
|
6089
|
+
* Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
|
|
6090
|
+
* null and logs when the payload is malformed. Exported for unit testing.
|
|
6091
|
+
*/
|
|
6092
|
+
function parseSummaries(json) {
|
|
6093
|
+
let payload;
|
|
6094
|
+
try {
|
|
6095
|
+
payload = JSON.parse(json);
|
|
6096
|
+
} catch (e) {
|
|
6097
|
+
error(`failed to parse summaries JSON: ${e.message}`);
|
|
6098
|
+
return null;
|
|
6099
|
+
}
|
|
6100
|
+
if (typeof payload !== "object" || payload === null) {
|
|
6101
|
+
error("summaries payload is not an object");
|
|
6102
|
+
return null;
|
|
6103
|
+
}
|
|
6104
|
+
const summaries = payload.summaries;
|
|
6105
|
+
if (!Array.isArray(summaries)) {
|
|
6106
|
+
error("summaries payload missing a `summaries` array");
|
|
6107
|
+
return null;
|
|
6108
|
+
}
|
|
6109
|
+
const out = [];
|
|
6110
|
+
for (const item of summaries) {
|
|
6111
|
+
const rec = item ?? {};
|
|
6112
|
+
const { featureName, specName, summary } = rec;
|
|
6113
|
+
if (typeof featureName === "string" && typeof specName === "string" && typeof summary === "string") {
|
|
6114
|
+
const entry = {
|
|
6115
|
+
featureName,
|
|
6116
|
+
specName,
|
|
6117
|
+
summary
|
|
6118
|
+
};
|
|
6119
|
+
if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
|
|
6120
|
+
if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
|
|
6121
|
+
if (Array.isArray(rec.preconditions)) {
|
|
6122
|
+
const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
|
|
6123
|
+
if (pre.length > 0) entry.preconditions = pre;
|
|
6124
|
+
}
|
|
6125
|
+
out.push(entry);
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
6128
|
+
return out;
|
|
6129
|
+
}
|
|
6130
|
+
const LABELS_JA = {
|
|
6131
|
+
indexTitle: "テスト観点インデックス (perspectives)",
|
|
6132
|
+
caseCol: "ケース",
|
|
6133
|
+
itemCol: "項目",
|
|
6134
|
+
valueCol: "内容",
|
|
6135
|
+
summary: "検証内容",
|
|
6136
|
+
preconditions: "前提条件",
|
|
6137
|
+
startScreen: "開始画面",
|
|
6138
|
+
relatedCode: "関連コード"
|
|
6139
|
+
};
|
|
6140
|
+
const LABELS_EN = {
|
|
6141
|
+
indexTitle: "Test Perspectives (perspectives)",
|
|
6142
|
+
caseCol: "Case",
|
|
6143
|
+
itemCol: "Item",
|
|
6144
|
+
valueCol: "Value",
|
|
6145
|
+
summary: "Verifies",
|
|
6146
|
+
preconditions: "Preconditions",
|
|
6147
|
+
startScreen: "Start screen",
|
|
6148
|
+
relatedCode: "Related code"
|
|
6149
|
+
};
|
|
6150
|
+
/**
|
|
6151
|
+
* Pick the label set for a `--language` value. Only an explicit English tag
|
|
6152
|
+
* (`en`, `en-US`, …) switches to English labels; `auto`, `ja`, and anything
|
|
6153
|
+
* else keep Japanese, matching the source-following default the rest of the
|
|
6154
|
+
* command uses.
|
|
6155
|
+
*/
|
|
6156
|
+
function labelsFor(language) {
|
|
6157
|
+
return /^en\b/i.test(language?.trim() ?? "") ? LABELS_EN : LABELS_JA;
|
|
6158
|
+
}
|
|
6159
|
+
/**
|
|
6160
|
+
* Path to a spec.yaml relative to the **root** `.ccqa/perspectives.md`
|
|
6161
|
+
* (i.e. relative to the `.ccqa/` dir). Used for the category index links.
|
|
6162
|
+
*/
|
|
6163
|
+
function specRelPathFromRoot(featureName, specName) {
|
|
6164
|
+
return `features/${featureName}/test-cases/${specName}/spec.yaml`;
|
|
6165
|
+
}
|
|
6166
|
+
/**
|
|
6167
|
+
* Path to a category detail file relative to the **root** `.ccqa/perspectives.md`.
|
|
6168
|
+
* The detail file is written to `.ccqa/features/<feature>/perspectives.md`
|
|
6169
|
+
* (see `getFeaturePerspectivesMarkdownPath`), so the link must include the
|
|
6170
|
+
* `features/` segment — otherwise the category heading link 404s.
|
|
6171
|
+
*/
|
|
6172
|
+
function featureDetailRelPathFromRoot(featureName) {
|
|
6173
|
+
return `features/${featureName}/perspectives.md`;
|
|
6174
|
+
}
|
|
6175
|
+
/**
|
|
6176
|
+
* Path to a spec.yaml relative to the **category** detail file
|
|
6177
|
+
* `.ccqa/features/<feature>/perspectives.md`. The spec lives alongside under
|
|
6178
|
+
* `test-cases/<spec>/`, so the category file links to it directly — which is
|
|
6179
|
+
* what makes the link resolve both on GitHub and in a local editor.
|
|
6180
|
+
*/
|
|
6181
|
+
function specRelPathFromCategory(specName) {
|
|
6182
|
+
return `test-cases/${specName}/spec.yaml`;
|
|
6183
|
+
}
|
|
6184
|
+
/**
|
|
6185
|
+
* Render the root `.ccqa/perspectives.md`: a category-grouped index of which
|
|
6186
|
+
* cases exist. Each feature is a heading (linking to its own detail
|
|
6187
|
+
* `perspectives.md`) followed by a row per case — title, status, and a link
|
|
6188
|
+
* to that case's spec.yaml. The per-case *detail* (検証内容, preconditions,
|
|
6189
|
+
* note) still lives only in the per-category file; the root stays a scannable
|
|
6190
|
+
* "what is tested, and where" overview.
|
|
6191
|
+
*
|
|
6192
|
+
* Pure and deterministic, so the index rendering is easy to unit-test.
|
|
6193
|
+
*/
|
|
6194
|
+
function renderIndexMarkdown(perspectives, labels = LABELS_JA) {
|
|
6195
|
+
const lines = [];
|
|
6196
|
+
lines.push(`# ${labels.indexTitle}`);
|
|
6197
|
+
lines.push("");
|
|
6198
|
+
for (const feature of perspectives.features) {
|
|
6199
|
+
const detailLink = featureDetailRelPathFromRoot(feature.featureName);
|
|
6200
|
+
lines.push(`## [${feature.featureName}](${detailLink})`);
|
|
6201
|
+
lines.push("");
|
|
6202
|
+
lines.push(`| ${labels.caseCol} | spec |`);
|
|
6203
|
+
lines.push("| --- | --- |");
|
|
6204
|
+
for (const spec of feature.specs) {
|
|
6205
|
+
const specLink = specRelPathFromRoot(feature.featureName, spec.specName);
|
|
6206
|
+
lines.push(`| ${mdCell(spec.title)} | [spec](${specLink}) |`);
|
|
6207
|
+
}
|
|
6208
|
+
lines.push("");
|
|
6209
|
+
}
|
|
6210
|
+
return lines.join("\n");
|
|
6211
|
+
}
|
|
6212
|
+
/**
|
|
6213
|
+
* Render one category's `.ccqa/features/<feature>/perspectives.md`: every
|
|
6214
|
+
* case in the category as a self-contained vertical table. All columns —
|
|
6215
|
+
* including the verification summary (検証内容) and the human note — live
|
|
6216
|
+
* inside the table; nothing is emitted outside it. Detailed steps / expected
|
|
6217
|
+
* results are still not restated (the spec.yaml is their single home); the
|
|
6218
|
+
* table links back to each spec instead.
|
|
6219
|
+
*
|
|
6220
|
+
* Pure and deterministic, so the per-case rendering is easy to unit-test.
|
|
6221
|
+
*/
|
|
6222
|
+
function renderFeatureMarkdown(feature, labels = LABELS_JA) {
|
|
6223
|
+
const lines = [];
|
|
6224
|
+
lines.push(`# ${feature.featureName}`);
|
|
6225
|
+
lines.push("");
|
|
6226
|
+
for (const spec of feature.specs) lines.push(...renderSpecMarkdown(spec, labels));
|
|
6227
|
+
return lines.join("\n");
|
|
6228
|
+
}
|
|
6229
|
+
/**
|
|
6230
|
+
* Render one spec as a single vertical (item | content) Markdown table for a
|
|
6231
|
+
* category file. Verification summary and preconditions lead. The spec link
|
|
6232
|
+
* is relative to this category file so it resolves both on GitHub and in a
|
|
6233
|
+
* local editor. Related-code paths stay inline code rather than links: their
|
|
6234
|
+
* base (the cwd that hosts `.ccqa/`) is not reliably recoverable here — specs
|
|
6235
|
+
* carry a mix of cwd-relative (`src/...`) and repo-root (`pkg/app/src/...`)
|
|
6236
|
+
* forms — and many are globs that no link could open anyway. 検証内容
|
|
6237
|
+
* (summary) and note are rows inside the table; no prose blocks are emitted
|
|
6238
|
+
* around it. Exported for focused unit testing.
|
|
6239
|
+
*/
|
|
6240
|
+
function renderSpecMarkdown(spec, labels = LABELS_JA) {
|
|
6241
|
+
const lines = [];
|
|
6242
|
+
lines.push(`## ${spec.title}`);
|
|
6243
|
+
lines.push("");
|
|
6244
|
+
lines.push(`| ${labels.itemCol} | ${labels.valueCol} |`);
|
|
6245
|
+
lines.push("| --- | --- |");
|
|
6246
|
+
if (spec.summary) lines.push(`| ${labels.summary} | ${mdCell(spec.summary)} |`);
|
|
6247
|
+
if (spec.preconditions && spec.preconditions.length > 0) lines.push(`| ${labels.preconditions} | ${spec.preconditions.map(mdCell).join("<br>")} |`);
|
|
6248
|
+
if (spec.startScreen) lines.push(`| ${labels.startScreen} | ${mdCell(spec.startScreen)} |`);
|
|
6249
|
+
const specPath = specRelPathFromCategory(spec.specName);
|
|
6250
|
+
lines.push(`| spec | [${specPath}](${specPath}) |`);
|
|
6251
|
+
if (spec.relatedPaths && spec.relatedPaths.length > 0) lines.push(`| ${labels.relatedCode} | ${spec.relatedPaths.map((p) => `\`${p}\``).join("<br>")} |`);
|
|
6252
|
+
if (spec.note) lines.push(`| 📝 note | ${mdCell(spec.note)} |`);
|
|
6253
|
+
lines.push("");
|
|
6254
|
+
return lines;
|
|
6255
|
+
}
|
|
6256
|
+
/** Escape pipes / newlines so a value stays inside one Markdown table cell. */
|
|
6257
|
+
function mdCell(value) {
|
|
6258
|
+
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
6259
|
+
}
|
|
6260
|
+
//#endregion
|
|
5653
6261
|
//#region src/cli/index.ts
|
|
5654
6262
|
const packageJsonPath = resolvePackageJson();
|
|
5655
6263
|
const { version } = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
@@ -5667,6 +6275,7 @@ const program = new Command();
|
|
|
5667
6275
|
program.name("ccqa").description("E2E test CLI using Claude Code + agent-browser").version(version);
|
|
5668
6276
|
program.addCommand(draftCommand);
|
|
5669
6277
|
program.addCommand(driftCommand);
|
|
6278
|
+
program.addCommand(perspectivesCommand);
|
|
5670
6279
|
program.addCommand(traceCommand);
|
|
5671
6280
|
program.addCommand(generateCommand);
|
|
5672
6281
|
program.addCommand(runCommand);
|
package/dist/package.json
CHANGED