ccqa 1.0.0 → 1.1.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 +3 -0
- package/dist/bin/ccqa.mjs +2059 -1461
- package/dist/hub-client/index.d.mts +10 -0
- package/dist/hub-client/index.mjs +25 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -414,55 +414,20 @@ async function saveSpecFile(featureName, specName, content, cwd) {
|
|
|
414
414
|
await writeFile(specPath, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
415
415
|
return specPath;
|
|
416
416
|
}
|
|
417
|
-
/** Absolute path to the single repo-wide `.ccqa/perspectives.yaml`. */
|
|
418
|
-
function getPerspectivesPath(cwd) {
|
|
419
|
-
return join(getCcqaDir(cwd), PERSPECTIVES_FILE);
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Read `.ccqa/perspectives.yaml` raw. Returns null when the file does not
|
|
423
|
-
* exist (first-ever generation) so callers can treat it as optional.
|
|
424
|
-
*/
|
|
425
|
-
async function tryReadPerspectives(cwd) {
|
|
426
|
-
return readFile(getPerspectivesPath(cwd), "utf-8").catch(() => null);
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Write `.ccqa/perspectives.yaml`. Mirrors `saveSpecFile`: ensures the
|
|
430
|
-
* directory exists and the content ends in a trailing newline.
|
|
431
|
-
*/
|
|
432
|
-
async function savePerspectives(content, cwd) {
|
|
433
|
-
await mkdir(getCcqaDir(cwd), { recursive: true });
|
|
434
|
-
const path = getPerspectivesPath(cwd);
|
|
435
|
-
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
436
|
-
return path;
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Human-readable Markdown companion to perspectives.yaml. The `.yaml` is the
|
|
440
|
-
* machine-readable source of truth; the `.md` is a rendered view for review.
|
|
441
|
-
*/
|
|
442
|
-
function getPerspectivesMarkdownPath(cwd) {
|
|
443
|
-
return join(getCcqaDir(cwd), PERSPECTIVES_MD_FILE);
|
|
444
|
-
}
|
|
445
|
-
async function savePerspectivesMarkdown(content, cwd) {
|
|
446
|
-
await mkdir(getCcqaDir(cwd), { recursive: true });
|
|
447
|
-
const path = getPerspectivesMarkdownPath(cwd);
|
|
448
|
-
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
449
|
-
return path;
|
|
450
|
-
}
|
|
451
417
|
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* in isolation.
|
|
418
|
+
* The perspectives document now lives on the hub only. Earlier versions wrote
|
|
419
|
+
* it into the repo as `.ccqa/perspectives.yaml` + `.ccqa/perspectives.md` +
|
|
420
|
+
* `.ccqa/features/<feature>/perspectives.md`; remove any of those leftovers.
|
|
421
|
+
* Returns the paths that were actually deleted.
|
|
457
422
|
*/
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
return
|
|
423
|
+
async function removeLegacyPerspectivesFiles(cwd) {
|
|
424
|
+
const candidates = [join(getCcqaDir(cwd), PERSPECTIVES_FILE), join(getCcqaDir(cwd), PERSPECTIVES_MD_FILE)];
|
|
425
|
+
const featuresDir = join(getCcqaDir(cwd), "features");
|
|
426
|
+
const featureNames = await readdir(featuresDir).catch(() => []);
|
|
427
|
+
for (const name of featureNames) candidates.push(join(featuresDir, name, PERSPECTIVES_MD_FILE));
|
|
428
|
+
const removed = [];
|
|
429
|
+
for (const path of candidates) if (await unlink(path).then(() => true).catch(() => false)) removed.push(path);
|
|
430
|
+
return removed;
|
|
466
431
|
}
|
|
467
432
|
/**
|
|
468
433
|
* Replace (or insert) the `relatedPaths` key in the spec. Preserves every
|
|
@@ -11489,543 +11454,247 @@ async function resolveCwdPrefix(cwd) {
|
|
|
11489
11454
|
}
|
|
11490
11455
|
}
|
|
11491
11456
|
//#endregion
|
|
11492
|
-
//#region src/
|
|
11493
|
-
const AUTO_FIX_MODES = [
|
|
11494
|
-
"interactive",
|
|
11495
|
-
"auto",
|
|
11496
|
-
"skip"
|
|
11497
|
-
];
|
|
11498
|
-
function toFixMode(autoFix) {
|
|
11499
|
-
switch (autoFix) {
|
|
11500
|
-
case "auto": return "auto";
|
|
11501
|
-
case "skip": return "non-interactive";
|
|
11502
|
-
case "interactive": return "interactive";
|
|
11503
|
-
}
|
|
11504
|
-
}
|
|
11505
|
-
/** Shared `--auto-fix` parser for the record / generate commands. */
|
|
11506
|
-
function parseAutoFixFlag(raw) {
|
|
11507
|
-
if (AUTO_FIX_MODES.includes(raw)) return raw;
|
|
11508
|
-
throw new Error(`--auto-fix must be one of ${AUTO_FIX_MODES.join(" | ")}`);
|
|
11509
|
-
}
|
|
11457
|
+
//#region src/prompts/perspectives.ts
|
|
11510
11458
|
/**
|
|
11511
|
-
*
|
|
11512
|
-
*
|
|
11513
|
-
*
|
|
11514
|
-
*
|
|
11515
|
-
* exit-code policy — while the plugin owns the generation pipeline.
|
|
11459
|
+
* Build the system prompt. By default the descriptive fields follow the
|
|
11460
|
+
* spec's own language (Japanese specs → Japanese fields). An explicit
|
|
11461
|
+
* `--language` is applied by the CLI via `languageDirective`, appended to
|
|
11462
|
+
* this prompt, so the language handling lives in one shared place.
|
|
11516
11463
|
*/
|
|
11517
|
-
|
|
11518
|
-
|
|
11519
|
-
|
|
11520
|
-
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11464
|
+
function buildPerspectivesSystemPrompt() {
|
|
11465
|
+
return `You produce a factual inventory of the E2E test coverage that already exists in a ccqa project.
|
|
11466
|
+
|
|
11467
|
+
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.
|
|
11468
|
+
|
|
11469
|
+
## Hard boundaries (do NOT cross)
|
|
11470
|
+
|
|
11471
|
+
- 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.
|
|
11472
|
+
- Do NOT do gap analysis. Do NOT list untested areas, missing coverage, or things the code has but the tests lack.
|
|
11473
|
+
- Do NOT evaluate whether the feature is good, complete, or correct.
|
|
11474
|
+
- Do NOT propose new test cases.
|
|
11475
|
+
- 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.
|
|
11476
|
+
- Do NOT touch status, relatedPaths, feature names, or spec names — the CLI already fixed those.
|
|
11477
|
+
|
|
11478
|
+
## Fields to write (per spec)
|
|
11479
|
+
|
|
11480
|
+
- \`summary\`: 1–2 sentences, factual and neutral. What the test exercises and what it ultimately asserts, derived from the spec's \`steps\` (\`instruction\` / \`expected\`).
|
|
11481
|
+
- \`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.
|
|
11482
|
+
- \`testCondition\`: the state/precondition the scenario assumes, phrased as a condition (e.g. "Logged in as an admin", "Unauthenticated user"). Omit if none.
|
|
11483
|
+
- \`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.
|
|
11484
|
+
|
|
11485
|
+
## How to write
|
|
11486
|
+
|
|
11487
|
+
- Same language as the spec's title (if titles are Japanese, write these fields in Japanese).
|
|
11488
|
+
- Keep each field short. These are index entries, not the test itself.
|
|
11489
|
+
- You may use Read/Grep/Glob sparingly to clarify domain vocabulary, but the steps are the primary source. Do not over-explore.
|
|
11490
|
+
|
|
11491
|
+
## Output contract (STRICT)
|
|
11492
|
+
|
|
11493
|
+
Output exactly ONE fenced \`\`\`json code block, and nothing else outside it. No prose before or after.
|
|
11494
|
+
|
|
11495
|
+
Schema:
|
|
11496
|
+
|
|
11497
|
+
\`\`\`json
|
|
11498
|
+
{
|
|
11499
|
+
"summaries": [
|
|
11500
|
+
{
|
|
11501
|
+
"featureName": "<verbatim from input>",
|
|
11502
|
+
"specName": "<verbatim from input>",
|
|
11503
|
+
"summary": "<1–2 sentence factual description of what this test verifies>",
|
|
11504
|
+
"startScreen": "<opening screen/URL, or omit>",
|
|
11505
|
+
"testCondition": "<assumed state phrased as a condition, or omit>",
|
|
11506
|
+
"preconditions": ["<setup prerequisite>", "..."]
|
|
11507
|
+
}
|
|
11508
|
+
]
|
|
11527
11509
|
}
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
const target = opts.targetOverride !== void 0 ? resolveTargetOverride(spec, opts.targetOverride) : resolveTarget(spec, config);
|
|
11533
|
-
meta("target", target.id + (opts.targetOverride !== void 0 ? " (--target override)" : ""));
|
|
11534
|
-
const existingOutput = await target.existingOutput?.({
|
|
11535
|
-
featureName,
|
|
11536
|
-
specName
|
|
11537
|
-
}, cwd) ?? null;
|
|
11538
|
-
if (existingOutput && !opts.force) {
|
|
11539
|
-
if (!await confirmOverwrite(existingOutput)) {
|
|
11540
|
-
info("aborted; pass --force to overwrite without prompting");
|
|
11541
|
-
return;
|
|
11542
|
-
}
|
|
11543
|
-
}
|
|
11544
|
-
let recording;
|
|
11545
|
-
if (target.input === "recording") {
|
|
11546
|
-
const { path: recordingPath, actions } = await getRecording(featureName, specName, cwd);
|
|
11547
|
-
meta("recording", recordingPath);
|
|
11548
|
-
meta("actions", actions.length);
|
|
11549
|
-
recording = actions;
|
|
11550
|
-
}
|
|
11551
|
-
await warnStaleBlockArtifacts();
|
|
11552
|
-
const targetConfig = config.targets[target.id] ?? TargetConfigSchema.parse({});
|
|
11553
|
-
const ctx = {
|
|
11554
|
-
spec,
|
|
11555
|
-
specYaml,
|
|
11556
|
-
featureName,
|
|
11557
|
-
specName,
|
|
11558
|
-
cwd,
|
|
11559
|
-
recording,
|
|
11560
|
-
resources: targetConfig.resources,
|
|
11561
|
-
conventions: targetConfig.conventions,
|
|
11562
|
-
targetConfig,
|
|
11563
|
-
language: opts.language,
|
|
11564
|
-
model: opts.model,
|
|
11565
|
-
hub: opts.hubContext ?? null,
|
|
11566
|
-
fix: {
|
|
11567
|
-
maxRetries: opts.maxRetries,
|
|
11568
|
-
mode: opts.fixMode,
|
|
11569
|
-
useSnapshot: opts.useSnapshot
|
|
11570
|
-
}
|
|
11571
|
-
};
|
|
11572
|
-
if (!(await target.generate(ctx)).passed) {
|
|
11573
|
-
warn("auto-fix exhausted; test still failing");
|
|
11574
|
-
process.exit(1);
|
|
11575
|
-
}
|
|
11576
|
-
hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
|
|
11510
|
+
\`\`\`
|
|
11511
|
+
|
|
11512
|
+
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.
|
|
11513
|
+
`;
|
|
11577
11514
|
}
|
|
11578
|
-
|
|
11579
|
-
|
|
11580
|
-
|
|
11581
|
-
|
|
11515
|
+
function buildPerspectivesPrompt(specs, instruction) {
|
|
11516
|
+
return `## Existing test cases to summarise
|
|
11517
|
+
|
|
11518
|
+
${specs.map((s) => `### ${s.featureName}/${s.specName}
|
|
11519
|
+
title: ${s.title}
|
|
11520
|
+
|
|
11521
|
+
\`\`\`yaml
|
|
11522
|
+
${s.specYaml.trimEnd()}
|
|
11523
|
+
\`\`\`
|
|
11524
|
+
`).join("\n")}
|
|
11525
|
+
${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
|
|
11526
|
+
|
|
11527
|
+
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.
|
|
11528
|
+
`;
|
|
11529
|
+
}
|
|
11530
|
+
//#endregion
|
|
11531
|
+
//#region src/cli/draft.ts
|
|
11532
|
+
const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
|
|
11533
|
+
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) => {
|
|
11534
|
+
await ensureCcqaDir();
|
|
11535
|
+
let featureName;
|
|
11536
|
+
let specName;
|
|
11537
|
+
let prefilledIntent = null;
|
|
11538
|
+
if (specPath) ({featureName, specName} = parseSpecPath(specPath));
|
|
11539
|
+
else {
|
|
11540
|
+
const { naming, intent } = await proposeNaming(opts);
|
|
11541
|
+
featureName = naming.featureName;
|
|
11542
|
+
specName = naming.specName;
|
|
11543
|
+
prefilledIntent = intent;
|
|
11582
11544
|
}
|
|
11583
|
-
|
|
11584
|
-
|
|
11585
|
-
|
|
11586
|
-
});
|
|
11587
|
-
|
|
11588
|
-
|
|
11589
|
-
|
|
11590
|
-
|
|
11591
|
-
const
|
|
11592
|
-
|
|
11593
|
-
|
|
11594
|
-
|
|
11545
|
+
await runDraft(featureName, specName, opts, prefilledIntent);
|
|
11546
|
+
});
|
|
11547
|
+
async function runDraft(featureName, specName, opts, prefilledIntent) {
|
|
11548
|
+
header("draft", `${featureName}/${specName}`);
|
|
11549
|
+
const ja = useJapanesePrompts(opts.language);
|
|
11550
|
+
const oneShot = opts.instruction !== void 0;
|
|
11551
|
+
let useIntentOnce = prefilledIntent !== null && !oneShot;
|
|
11552
|
+
while (true) {
|
|
11553
|
+
const existing = await tryReadSpecFile(featureName, specName);
|
|
11554
|
+
const isFirstRun = existing === null;
|
|
11555
|
+
let userInput;
|
|
11556
|
+
if (oneShot) userInput = opts.instruction ?? "";
|
|
11557
|
+
else if (useIntentOnce && isFirstRun) {
|
|
11558
|
+
userInput = prefilledIntent ?? "";
|
|
11559
|
+
useIntentOnce = false;
|
|
11560
|
+
} else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
|
|
11561
|
+
if (isFirstRun && !userInput.trim()) {
|
|
11562
|
+
error("intent required for the first draft (no spec exists yet)");
|
|
11563
|
+
process.exit(1);
|
|
11564
|
+
}
|
|
11565
|
+
const turnResult = await runOneTurn({
|
|
11566
|
+
featureName,
|
|
11567
|
+
specName,
|
|
11568
|
+
existing,
|
|
11569
|
+
userInput: userInput.trim(),
|
|
11570
|
+
autoApply: opts.apply === true,
|
|
11571
|
+
language: opts.language
|
|
11572
|
+
});
|
|
11573
|
+
if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
|
|
11574
|
+
blank();
|
|
11575
|
+
if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
|
|
11576
|
+
info("draft session complete.");
|
|
11577
|
+
hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
|
|
11578
|
+
process.exit(0);
|
|
11579
|
+
}
|
|
11595
11580
|
}
|
|
11596
11581
|
}
|
|
11597
|
-
|
|
11598
|
-
const { featureName, specName } =
|
|
11599
|
-
const
|
|
11600
|
-
const
|
|
11601
|
-
const
|
|
11602
|
-
|
|
11603
|
-
|
|
11604
|
-
|
|
11605
|
-
cwd,
|
|
11606
|
-
hubUrl: opts.hubUrl,
|
|
11607
|
-
hubToken: opts.hubToken,
|
|
11608
|
-
hubHeader: opts.hubHeader
|
|
11609
|
-
});
|
|
11610
|
-
else await applyProfileFromOption({
|
|
11611
|
-
profile: void 0,
|
|
11612
|
-
project: "",
|
|
11613
|
-
cwd
|
|
11582
|
+
async function runOneTurn(input) {
|
|
11583
|
+
const { featureName, specName, existing, userInput, autoApply, language } = input;
|
|
11584
|
+
const isFirstRun = existing === null;
|
|
11585
|
+
const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
|
|
11586
|
+
const userPrompt = buildDraftPrompt({
|
|
11587
|
+
mode: isFirstRun ? "create" : "refine",
|
|
11588
|
+
existing: existing ?? "",
|
|
11589
|
+
userInput
|
|
11614
11590
|
});
|
|
11615
|
-
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11591
|
+
info(isFirstRun ? "Reading codebase and drafting spec..." : "Re-validating spec against codebase...");
|
|
11592
|
+
const toolCounts = {};
|
|
11593
|
+
const startedAt = Date.now();
|
|
11594
|
+
const { result, isError } = await invokeClaudeStreaming({
|
|
11595
|
+
prompt: userPrompt,
|
|
11596
|
+
systemPrompt,
|
|
11597
|
+
allowedTools: [
|
|
11598
|
+
"Read",
|
|
11599
|
+
"Grep",
|
|
11600
|
+
"Glob"
|
|
11601
|
+
],
|
|
11602
|
+
silenceBashLog: true
|
|
11603
|
+
}, (msg) => {
|
|
11604
|
+
if (msg.type !== "assistant") return;
|
|
11605
|
+
for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
|
|
11619
11606
|
});
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
11607
|
+
printToolSummary(toolCounts, Date.now() - startedAt);
|
|
11608
|
+
if (isError) {
|
|
11609
|
+
error("Claude returned an error result");
|
|
11610
|
+
return {
|
|
11611
|
+
hasError: true,
|
|
11612
|
+
applied: false
|
|
11613
|
+
};
|
|
11614
|
+
}
|
|
11615
|
+
const json = extractJsonBlock(result);
|
|
11616
|
+
if (!json) {
|
|
11617
|
+
error("Claude did not return a json block");
|
|
11618
|
+
warn(`raw tail: ${truncate(result, 200)}`);
|
|
11619
|
+
return {
|
|
11620
|
+
hasError: true,
|
|
11621
|
+
applied: false
|
|
11622
|
+
};
|
|
11623
|
+
}
|
|
11624
|
+
let report;
|
|
11624
11625
|
try {
|
|
11625
|
-
|
|
11626
|
-
maxRetries: parseInt(opts.maxRetries ?? "3", 10),
|
|
11627
|
-
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
11628
|
-
force: opts.force ?? false,
|
|
11629
|
-
useSnapshot: opts.snapshot !== false,
|
|
11630
|
-
language,
|
|
11631
|
-
model: opts.model,
|
|
11632
|
-
targetOverride: opts.target,
|
|
11633
|
-
cwd,
|
|
11634
|
-
hubContext
|
|
11635
|
-
});
|
|
11626
|
+
report = DraftReportSchema.parse(JSON.parse(json));
|
|
11636
11627
|
} catch (e) {
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
11628
|
+
error(`failed to parse draft report: ${e.message}`);
|
|
11629
|
+
return {
|
|
11630
|
+
hasError: true,
|
|
11631
|
+
applied: false
|
|
11632
|
+
};
|
|
11642
11633
|
}
|
|
11643
|
-
|
|
11644
|
-
|
|
11645
|
-
|
|
11646
|
-
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
const language = opts.language ?? "auto";
|
|
11653
|
-
if (opts.skipTrace && opts.skipCodegen) {
|
|
11654
|
-
error("--skip-trace and --skip-codegen cannot be combined; nothing would run");
|
|
11655
|
-
process.exit(2);
|
|
11634
|
+
const hasError = printReviewBlock(report.issues);
|
|
11635
|
+
const original = existing ?? "";
|
|
11636
|
+
if (!report.patch || report.patch === original) {
|
|
11637
|
+
blank();
|
|
11638
|
+
info("no changes proposed.");
|
|
11639
|
+
return {
|
|
11640
|
+
hasError,
|
|
11641
|
+
applied: false
|
|
11642
|
+
};
|
|
11656
11643
|
}
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11644
|
+
blank();
|
|
11645
|
+
info("--- proposed changes ---");
|
|
11646
|
+
printUnifiedDiff(original, report.patch);
|
|
11647
|
+
blank();
|
|
11648
|
+
if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
|
|
11649
|
+
info("aborted — no changes applied.");
|
|
11650
|
+
return {
|
|
11651
|
+
hasError,
|
|
11652
|
+
applied: false
|
|
11653
|
+
};
|
|
11662
11654
|
}
|
|
11663
|
-
|
|
11664
|
-
|
|
11665
|
-
|
|
11666
|
-
|
|
11667
|
-
|
|
11668
|
-
|
|
11669
|
-
|
|
11670
|
-
|
|
11671
|
-
}
|
|
11672
|
-
|
|
11673
|
-
|
|
11674
|
-
|
|
11675
|
-
|
|
11676
|
-
}
|
|
11677
|
-
|
|
11678
|
-
|
|
11679
|
-
|
|
11680
|
-
|
|
11655
|
+
try {
|
|
11656
|
+
parseTestSpec(report.patch);
|
|
11657
|
+
} catch (e) {
|
|
11658
|
+
error(`refused to apply: patch failed validation (${e.message})`);
|
|
11659
|
+
return {
|
|
11660
|
+
hasError: true,
|
|
11661
|
+
applied: false
|
|
11662
|
+
};
|
|
11663
|
+
}
|
|
11664
|
+
meta("saved", await saveSpecFile(featureName, specName, report.patch));
|
|
11665
|
+
return {
|
|
11666
|
+
hasError,
|
|
11667
|
+
applied: true
|
|
11668
|
+
};
|
|
11669
|
+
}
|
|
11670
|
+
async function prompt(question) {
|
|
11671
|
+
const rl = createInterface$1({
|
|
11672
|
+
input: process.stdin,
|
|
11673
|
+
output: process.stdout
|
|
11681
11674
|
});
|
|
11682
|
-
|
|
11683
|
-
|
|
11684
|
-
|
|
11685
|
-
} : null;
|
|
11686
|
-
const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
|
|
11687
|
-
if (e instanceof SpecLockedError) {
|
|
11688
|
-
error(e.message);
|
|
11689
|
-
process.exit(2);
|
|
11690
|
-
}
|
|
11691
|
-
throw e;
|
|
11675
|
+
rl.on("SIGINT", () => {
|
|
11676
|
+
rl.close();
|
|
11677
|
+
process.exit(130);
|
|
11692
11678
|
});
|
|
11693
|
-
let traceResult = null;
|
|
11694
11679
|
try {
|
|
11695
|
-
|
|
11696
|
-
traceResult = await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", language, {
|
|
11697
|
-
cwd: cwdForProfile,
|
|
11698
|
-
hubContext
|
|
11699
|
-
});
|
|
11700
|
-
blank();
|
|
11701
|
-
}
|
|
11702
|
-
if (!opts.skipCodegen) await runGenerate(featureName, specName, {
|
|
11703
|
-
maxRetries: parseInt(opts.maxRetries ?? "3", 10),
|
|
11704
|
-
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
11705
|
-
force: opts.force ?? false,
|
|
11706
|
-
useSnapshot: opts.snapshot !== false,
|
|
11707
|
-
language,
|
|
11708
|
-
model: opts.model,
|
|
11709
|
-
cwd: cwdForProfile,
|
|
11710
|
-
hubContext
|
|
11711
|
-
});
|
|
11680
|
+
return (await rl.question(question)).trim();
|
|
11712
11681
|
} finally {
|
|
11713
|
-
|
|
11714
|
-
}
|
|
11715
|
-
if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
|
|
11716
|
-
else {
|
|
11717
|
-
blank();
|
|
11718
|
-
await updateAgentPrompt({
|
|
11719
|
-
mode: "record",
|
|
11720
|
-
runSummary: buildRecordRunSummary(featureName, specName, traceResult),
|
|
11721
|
-
hubContext,
|
|
11722
|
-
...opts.model ? { model: opts.model } : {},
|
|
11723
|
-
...language ? { language } : {}
|
|
11724
|
-
});
|
|
11682
|
+
rl.close();
|
|
11725
11683
|
}
|
|
11726
|
-
}
|
|
11727
|
-
/**
|
|
11728
|
-
|
|
11729
|
-
|
|
11730
|
-
|
|
11731
|
-
|
|
11732
|
-
|
|
11733
|
-
|
|
11734
|
-
* it needs those exact tokens; the prose alone doesn't carry them. The
|
|
11735
|
-
* header's kept/recorded totals flag how much the run thrashed through
|
|
11736
|
-
* selectors overall.
|
|
11737
|
-
*/
|
|
11738
|
-
function buildRecordRunSummary(featureName, specName, t) {
|
|
11739
|
-
const header = `## ${featureName}/${specName} — ${t.status}\nActions: ${t.actionsKept} kept / ${t.actionsRecorded} recorded`;
|
|
11740
|
-
const steps = collectStepSummaries(t.statusLines);
|
|
11741
|
-
if (steps.length === 0) return `${header}\n\n(no step status lines recorded)`;
|
|
11742
|
-
const commandsByStep = groupCommandsByStep(t.actions);
|
|
11743
|
-
const observationsByStep = groupObservationsByStep(t.actions);
|
|
11744
|
-
const unstableByStep = countUnstableByStep(t.actions);
|
|
11745
|
-
return `${header}\n\n${steps.map((s) => {
|
|
11746
|
-
const cmds = commandsByStep.get(s.stepId) ?? [];
|
|
11747
|
-
const observations = observationsByStep.get(s.stepId) ?? [];
|
|
11748
|
-
const churn = t.churnByStep.get(s.stepId);
|
|
11749
|
-
const dropped = churn ? churn.recorded - churn.kept : 0;
|
|
11750
|
-
const redundant = churn?.redundant ?? 0;
|
|
11751
|
-
const unstable = unstableByStep.get(s.stepId) ?? 0;
|
|
11752
|
-
return [
|
|
11753
|
-
`### ${s.stepId} — ${oneLineSummary(s.title)} (${s.status})`,
|
|
11754
|
-
...s.detail ? [`- result: ${oneLineSummary(s.detail)}`] : [],
|
|
11755
|
-
...observations.length > 0 ? [`- observations: ${observations.map(oneLineSummary).join(" ; ")}`] : [],
|
|
11756
|
-
...dropped > 0 ? [`- churn: ${churn.recorded} attempts → ${churn.kept} kept (${dropped} dropped)`] : [],
|
|
11757
|
-
...redundant > 0 ? [`- redundant: ${redundant} field(s) entered via 2+ selectors (kept both — record one canonical selector)`] : [],
|
|
11758
|
-
...unstable > 0 ? [`- replay-unstable: ${unstable} kept command(s) marked [unstable] — do NOT record these as canonical; prefer a more stable locator even if it costs one more probe`] : [],
|
|
11759
|
-
...cmds.length > 0 ? [`- kept commands: ${cmds.join(" ; ")}`] : []
|
|
11760
|
-
].join("\n");
|
|
11761
|
-
}).join("\n\n")}`;
|
|
11684
|
+
}
|
|
11685
|
+
/** Aggregated tool-call counts shown after each Claude turn. */
|
|
11686
|
+
function formatToolSummary(counts, elapsedMs) {
|
|
11687
|
+
const entries = Object.entries(counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([name, n]) => `${n} ${name}`);
|
|
11688
|
+
return ` ✓ ${entries.length === 0 ? "no tool calls" : entries.join(", ")} (${(elapsedMs / 1e3).toFixed(1)}s)`;
|
|
11689
|
+
}
|
|
11690
|
+
function printToolSummary(counts, elapsedMs) {
|
|
11691
|
+
process.stdout.write(`${formatToolSummary(counts, elapsedMs)}\n`);
|
|
11762
11692
|
}
|
|
11763
11693
|
/**
|
|
11764
|
-
*
|
|
11765
|
-
*
|
|
11766
|
-
*
|
|
11767
|
-
*
|
|
11768
|
-
*/
|
|
11769
|
-
function collectStepSummaries(lines) {
|
|
11770
|
-
const byId = /* @__PURE__ */ new Map();
|
|
11771
|
-
const ordered = [];
|
|
11772
|
-
const ensure = (stepId, title) => {
|
|
11773
|
-
let entry = byId.get(stepId);
|
|
11774
|
-
if (!entry) {
|
|
11775
|
-
entry = {
|
|
11776
|
-
stepId,
|
|
11777
|
-
title,
|
|
11778
|
-
status: "NO_STATUS"
|
|
11779
|
-
};
|
|
11780
|
-
byId.set(stepId, entry);
|
|
11781
|
-
ordered.push(entry);
|
|
11782
|
-
}
|
|
11783
|
-
return entry;
|
|
11784
|
-
};
|
|
11785
|
-
for (const line of lines) {
|
|
11786
|
-
if (!line.stepId || line.type === "RUN_COMPLETED") continue;
|
|
11787
|
-
if (line.type === "STEP_START") {
|
|
11788
|
-
ensure(line.stepId, line.detail);
|
|
11789
|
-
continue;
|
|
11790
|
-
}
|
|
11791
|
-
const entry = ensure(line.stepId, "(untitled)");
|
|
11792
|
-
entry.status = line.type === "STEP_DONE" ? "DONE" : line.type === "ASSERTION_FAILED" ? "FAILED" : "SKIPPED";
|
|
11793
|
-
if (line.detail) entry.detail = line.detail;
|
|
11794
|
-
}
|
|
11795
|
-
return ordered;
|
|
11796
|
-
}
|
|
11797
|
-
/** Group each kept action's `action selector` form under its stepId. */
|
|
11798
|
-
function groupCommandsByStep(actions) {
|
|
11799
|
-
const byStep = /* @__PURE__ */ new Map();
|
|
11800
|
-
for (const a of actions) {
|
|
11801
|
-
if (!a.stepId) continue;
|
|
11802
|
-
const list = byStep.get(a.stepId) ?? [];
|
|
11803
|
-
list.push(formatRecordedAction(a));
|
|
11804
|
-
byStep.set(a.stepId, list);
|
|
11805
|
-
}
|
|
11806
|
-
return byStep;
|
|
11807
|
-
}
|
|
11808
|
-
/**
|
|
11809
|
-
* Group per-action observations (snapshot / assert prose) under their stepId
|
|
11810
|
-
* — the trace's own record of what it verified at each step.
|
|
11811
|
-
*/
|
|
11812
|
-
function groupObservationsByStep(actions) {
|
|
11813
|
-
const byStep = /* @__PURE__ */ new Map();
|
|
11814
|
-
for (const a of actions) {
|
|
11815
|
-
if (!a.stepId || !a.observation) continue;
|
|
11816
|
-
const list = byStep.get(a.stepId) ?? [];
|
|
11817
|
-
list.push(a.observation);
|
|
11818
|
-
byStep.set(a.stepId, list);
|
|
11819
|
-
}
|
|
11820
|
-
return byStep;
|
|
11821
|
-
}
|
|
11822
|
-
/** Per-step count of kept-but-replay-unstable actions (the flaky selectors). */
|
|
11823
|
-
function countUnstableByStep(actions) {
|
|
11824
|
-
const byStep = /* @__PURE__ */ new Map();
|
|
11825
|
-
for (const a of actions) {
|
|
11826
|
-
if (!a.stepId || !a.replayUnstable) continue;
|
|
11827
|
-
byStep.set(a.stepId, (byStep.get(a.stepId) ?? 0) + 1);
|
|
11828
|
-
}
|
|
11829
|
-
return byStep;
|
|
11830
|
-
}
|
|
11831
|
-
/**
|
|
11832
|
-
* One kept action as `action selector` (with the fill value or assert
|
|
11833
|
-
* type when present) — the canonical form the record playbook should reuse.
|
|
11834
|
-
* Unlike the live summary this keeps selectors verbatim: record learns
|
|
11835
|
-
* concrete per-spec selectors, so masking them would defeat the purpose.
|
|
11836
|
-
*
|
|
11837
|
-
* A replay-unstable action (kept in lenient mode but flagged because its
|
|
11838
|
-
* selector wasn't present on a fresh replay) is tagged `[unstable](<reason>)`
|
|
11839
|
-
* so the learner does NOT record its selector as canonical — the reason is the
|
|
11840
|
-
* teacher signal ("not present within Nms" = timing-fragile, not stable).
|
|
11841
|
-
*/
|
|
11842
|
-
function formatRecordedAction(a) {
|
|
11843
|
-
const parts = [a.action];
|
|
11844
|
-
if (a.index !== void 0) parts.push(String(a.index));
|
|
11845
|
-
const anchor = a.locator ? formatLocator(a.locator) : a.label;
|
|
11846
|
-
if (anchor) parts.push(anchor);
|
|
11847
|
-
if (a.value) parts.push(`= ${a.value}`);
|
|
11848
|
-
if (a.assert) parts.push(`(${a.assert})`);
|
|
11849
|
-
const line = oneLineSummary(parts.join(" "));
|
|
11850
|
-
return a.replayUnstable ? `${line} [unstable](${oneLineSummary(a.replayReason ?? "no reason")})` : line;
|
|
11851
|
-
}
|
|
11852
|
-
/** Verbatim locator form: raw selector for css, `by=value` for semantic ones. */
|
|
11853
|
-
function formatLocator(locator) {
|
|
11854
|
-
const name = locator.by === "role" && locator.name ? ` name=${locator.name}` : "";
|
|
11855
|
-
return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}${name}`;
|
|
11856
|
-
}
|
|
11857
|
-
function oneLineSummary(s) {
|
|
11858
|
-
const flat = s.replace(/\s+/g, " ").trim();
|
|
11859
|
-
return flat.length > 240 ? flat.slice(0, 240) + "…" : flat || "(none)";
|
|
11860
|
-
}
|
|
11861
|
-
//#endregion
|
|
11862
|
-
//#region src/cli/draft.ts
|
|
11863
|
-
const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
|
|
11864
|
-
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) => {
|
|
11865
|
-
await ensureCcqaDir();
|
|
11866
|
-
let featureName;
|
|
11867
|
-
let specName;
|
|
11868
|
-
let prefilledIntent = null;
|
|
11869
|
-
if (specPath) ({featureName, specName} = parseSpecPath(specPath));
|
|
11870
|
-
else {
|
|
11871
|
-
const { naming, intent } = await proposeNaming(opts);
|
|
11872
|
-
featureName = naming.featureName;
|
|
11873
|
-
specName = naming.specName;
|
|
11874
|
-
prefilledIntent = intent;
|
|
11875
|
-
}
|
|
11876
|
-
await runDraft(featureName, specName, opts, prefilledIntent);
|
|
11877
|
-
});
|
|
11878
|
-
async function runDraft(featureName, specName, opts, prefilledIntent) {
|
|
11879
|
-
header("draft", `${featureName}/${specName}`);
|
|
11880
|
-
const ja = useJapanesePrompts(opts.language);
|
|
11881
|
-
const oneShot = opts.instruction !== void 0;
|
|
11882
|
-
let useIntentOnce = prefilledIntent !== null && !oneShot;
|
|
11883
|
-
while (true) {
|
|
11884
|
-
const existing = await tryReadSpecFile(featureName, specName);
|
|
11885
|
-
const isFirstRun = existing === null;
|
|
11886
|
-
let userInput;
|
|
11887
|
-
if (oneShot) userInput = opts.instruction ?? "";
|
|
11888
|
-
else if (useIntentOnce && isFirstRun) {
|
|
11889
|
-
userInput = prefilledIntent ?? "";
|
|
11890
|
-
useIntentOnce = false;
|
|
11891
|
-
} else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
|
|
11892
|
-
if (isFirstRun && !userInput.trim()) {
|
|
11893
|
-
error("intent required for the first draft (no spec exists yet)");
|
|
11894
|
-
process.exit(1);
|
|
11895
|
-
}
|
|
11896
|
-
const turnResult = await runOneTurn({
|
|
11897
|
-
featureName,
|
|
11898
|
-
specName,
|
|
11899
|
-
existing,
|
|
11900
|
-
userInput: userInput.trim(),
|
|
11901
|
-
autoApply: opts.apply === true,
|
|
11902
|
-
language: opts.language
|
|
11903
|
-
});
|
|
11904
|
-
if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
|
|
11905
|
-
blank();
|
|
11906
|
-
if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
|
|
11907
|
-
info("draft session complete.");
|
|
11908
|
-
hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
|
|
11909
|
-
process.exit(0);
|
|
11910
|
-
}
|
|
11911
|
-
}
|
|
11912
|
-
}
|
|
11913
|
-
async function runOneTurn(input) {
|
|
11914
|
-
const { featureName, specName, existing, userInput, autoApply, language } = input;
|
|
11915
|
-
const isFirstRun = existing === null;
|
|
11916
|
-
const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
|
|
11917
|
-
const userPrompt = buildDraftPrompt({
|
|
11918
|
-
mode: isFirstRun ? "create" : "refine",
|
|
11919
|
-
existing: existing ?? "",
|
|
11920
|
-
userInput
|
|
11921
|
-
});
|
|
11922
|
-
info(isFirstRun ? "Reading codebase and drafting spec..." : "Re-validating spec against codebase...");
|
|
11923
|
-
const toolCounts = {};
|
|
11924
|
-
const startedAt = Date.now();
|
|
11925
|
-
const { result, isError } = await invokeClaudeStreaming({
|
|
11926
|
-
prompt: userPrompt,
|
|
11927
|
-
systemPrompt,
|
|
11928
|
-
allowedTools: [
|
|
11929
|
-
"Read",
|
|
11930
|
-
"Grep",
|
|
11931
|
-
"Glob"
|
|
11932
|
-
],
|
|
11933
|
-
silenceBashLog: true
|
|
11934
|
-
}, (msg) => {
|
|
11935
|
-
if (msg.type !== "assistant") return;
|
|
11936
|
-
for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
|
|
11937
|
-
});
|
|
11938
|
-
printToolSummary(toolCounts, Date.now() - startedAt);
|
|
11939
|
-
if (isError) {
|
|
11940
|
-
error("Claude returned an error result");
|
|
11941
|
-
return {
|
|
11942
|
-
hasError: true,
|
|
11943
|
-
applied: false
|
|
11944
|
-
};
|
|
11945
|
-
}
|
|
11946
|
-
const json = extractJsonBlock(result);
|
|
11947
|
-
if (!json) {
|
|
11948
|
-
error("Claude did not return a json block");
|
|
11949
|
-
warn(`raw tail: ${truncate(result, 200)}`);
|
|
11950
|
-
return {
|
|
11951
|
-
hasError: true,
|
|
11952
|
-
applied: false
|
|
11953
|
-
};
|
|
11954
|
-
}
|
|
11955
|
-
let report;
|
|
11956
|
-
try {
|
|
11957
|
-
report = DraftReportSchema.parse(JSON.parse(json));
|
|
11958
|
-
} catch (e) {
|
|
11959
|
-
error(`failed to parse draft report: ${e.message}`);
|
|
11960
|
-
return {
|
|
11961
|
-
hasError: true,
|
|
11962
|
-
applied: false
|
|
11963
|
-
};
|
|
11964
|
-
}
|
|
11965
|
-
const hasError = printReviewBlock(report.issues);
|
|
11966
|
-
const original = existing ?? "";
|
|
11967
|
-
if (!report.patch || report.patch === original) {
|
|
11968
|
-
blank();
|
|
11969
|
-
info("no changes proposed.");
|
|
11970
|
-
return {
|
|
11971
|
-
hasError,
|
|
11972
|
-
applied: false
|
|
11973
|
-
};
|
|
11974
|
-
}
|
|
11975
|
-
blank();
|
|
11976
|
-
info("--- proposed changes ---");
|
|
11977
|
-
printUnifiedDiff(original, report.patch);
|
|
11978
|
-
blank();
|
|
11979
|
-
if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
|
|
11980
|
-
info("aborted — no changes applied.");
|
|
11981
|
-
return {
|
|
11982
|
-
hasError,
|
|
11983
|
-
applied: false
|
|
11984
|
-
};
|
|
11985
|
-
}
|
|
11986
|
-
try {
|
|
11987
|
-
parseTestSpec(report.patch);
|
|
11988
|
-
} catch (e) {
|
|
11989
|
-
error(`refused to apply: patch failed validation (${e.message})`);
|
|
11990
|
-
return {
|
|
11991
|
-
hasError: true,
|
|
11992
|
-
applied: false
|
|
11993
|
-
};
|
|
11994
|
-
}
|
|
11995
|
-
meta("saved", await saveSpecFile(featureName, specName, report.patch));
|
|
11996
|
-
return {
|
|
11997
|
-
hasError,
|
|
11998
|
-
applied: true
|
|
11999
|
-
};
|
|
12000
|
-
}
|
|
12001
|
-
async function prompt(question) {
|
|
12002
|
-
const rl = createInterface$1({
|
|
12003
|
-
input: process.stdin,
|
|
12004
|
-
output: process.stdout
|
|
12005
|
-
});
|
|
12006
|
-
rl.on("SIGINT", () => {
|
|
12007
|
-
rl.close();
|
|
12008
|
-
process.exit(130);
|
|
12009
|
-
});
|
|
12010
|
-
try {
|
|
12011
|
-
return (await rl.question(question)).trim();
|
|
12012
|
-
} finally {
|
|
12013
|
-
rl.close();
|
|
12014
|
-
}
|
|
12015
|
-
}
|
|
12016
|
-
/** Aggregated tool-call counts shown after each Claude turn. */
|
|
12017
|
-
function formatToolSummary(counts, elapsedMs) {
|
|
12018
|
-
const entries = Object.entries(counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([name, n]) => `${n} ${name}`);
|
|
12019
|
-
return ` ✓ ${entries.length === 0 ? "no tool calls" : entries.join(", ")} (${(elapsedMs / 1e3).toFixed(1)}s)`;
|
|
12020
|
-
}
|
|
12021
|
-
function printToolSummary(counts, elapsedMs) {
|
|
12022
|
-
process.stdout.write(`${formatToolSummary(counts, elapsedMs)}\n`);
|
|
12023
|
-
}
|
|
12024
|
-
/**
|
|
12025
|
-
* Renders the review report as a visually separated block, grouped by
|
|
12026
|
-
* severity. ERROR and WARN findings get full detail; OK findings collapse
|
|
12027
|
-
* to a one-line summary of category names. Returns whether any ERROR
|
|
12028
|
-
* severity was emitted.
|
|
11694
|
+
* Renders the review report as a visually separated block, grouped by
|
|
11695
|
+
* severity. ERROR and WARN findings get full detail; OK findings collapse
|
|
11696
|
+
* to a one-line summary of category names. Returns whether any ERROR
|
|
11697
|
+
* severity was emitted.
|
|
12029
11698
|
*/
|
|
12030
11699
|
function printReviewBlock(issues) {
|
|
12031
11700
|
const RULE = "─".repeat(67);
|
|
@@ -12228,1013 +11897,1361 @@ function truncate(s, n) {
|
|
|
12228
11897
|
return s.slice(s.length - n);
|
|
12229
11898
|
}
|
|
12230
11899
|
//#endregion
|
|
12231
|
-
//#region src/
|
|
11900
|
+
//#region src/cli/perspectives.ts
|
|
11901
|
+
const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — 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("--check", "Verify the hub document still matches the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
|
|
11902
|
+
if (opts.check) await runPerspectivesCheck(opts);
|
|
11903
|
+
else await runPerspectives(opts);
|
|
11904
|
+
}));
|
|
12232
11905
|
/**
|
|
12233
|
-
*
|
|
12234
|
-
*
|
|
12235
|
-
*
|
|
11906
|
+
* `--check`: compare the hub document against a freshly-built local skeleton
|
|
11907
|
+
* on the CLI-owned mechanical fields only (the spec set, titles,
|
|
11908
|
+
* relatedPaths, status). Claude-authored descriptive fields and the human
|
|
11909
|
+
* note are deliberately not compared — they are not deterministic, so they
|
|
11910
|
+
* can't signal staleness. Exit 1 on any mismatch; this is the CI gate for
|
|
11911
|
+
* "someone changed the specs without the inventory catching up".
|
|
12236
11912
|
*/
|
|
12237
|
-
function
|
|
12238
|
-
|
|
12239
|
-
|
|
12240
|
-
|
|
12241
|
-
|
|
12242
|
-
const
|
|
12243
|
-
|
|
12244
|
-
|
|
12245
|
-
|
|
12246
|
-
|
|
12247
|
-
|
|
12248
|
-
const tail = "═".repeat(Math.max(3, 72 - heading.length));
|
|
12249
|
-
out.push(`${heading}${tail}`);
|
|
12250
|
-
if (r.error) {
|
|
12251
|
-
out.push(` ERROR ${r.error}`);
|
|
12252
|
-
continue;
|
|
12253
|
-
}
|
|
12254
|
-
const errors = r.issues.filter((i) => i.severity === "ERROR");
|
|
12255
|
-
const warnings = r.issues.filter((i) => i.severity === "WARN");
|
|
12256
|
-
const passed = r.issues.filter((i) => i.severity === "OK");
|
|
12257
|
-
if (errors.length === 0 && warnings.length === 0) {
|
|
12258
|
-
const label = passed.length === 1 ? "check" : "checks";
|
|
12259
|
-
const detail = passed.length > 0 ? `all ${passed.length} ${label} passed` : "no issues";
|
|
12260
|
-
out.push(` ✓ ${detail}`);
|
|
12261
|
-
continue;
|
|
12262
|
-
}
|
|
12263
|
-
for (const issue of errors) appendFinding(out, "ERROR", issue);
|
|
12264
|
-
for (const issue of warnings) appendFinding(out, "WARN", issue);
|
|
12265
|
-
if (passed.length > 0) {
|
|
12266
|
-
const names = passed.map((i) => DRAFT_CATEGORY_LABEL[i.category]).join(", ");
|
|
12267
|
-
out.push("");
|
|
12268
|
-
out.push(` ✓ passed (${passed.length}): ${names}`);
|
|
11913
|
+
async function runPerspectivesCheck(opts) {
|
|
11914
|
+
const hub = requireHubOrExit(opts);
|
|
11915
|
+
const project = resolveProject(opts);
|
|
11916
|
+
header("perspectives", `check (project: ${project})`);
|
|
11917
|
+
const skeleton = await buildSkeleton(await listFeatureTree());
|
|
11918
|
+
const localCount = skeleton.reduce((n, f) => n + f.specs.length, 0);
|
|
11919
|
+
const existingDoc = await hub.getPerspectives(project);
|
|
11920
|
+
if (existingDoc === null) {
|
|
11921
|
+
if (localCount === 0) {
|
|
11922
|
+
info("no local test cases and no hub document — nothing to check.");
|
|
11923
|
+
return;
|
|
12269
11924
|
}
|
|
11925
|
+
error(`no perspectives document on the hub for project "${project}" — run \`ccqa perspectives\` to create it`);
|
|
11926
|
+
process.exit(1);
|
|
12270
11927
|
}
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
12283
|
-
|
|
12284
|
-
|
|
12285
|
-
|
|
12286
|
-
|
|
12287
|
-
|
|
12288
|
-
feature: r.target.featureName,
|
|
12289
|
-
spec: r.target.specName,
|
|
12290
|
-
ok: r.ok,
|
|
12291
|
-
...r.error ? { error: r.error } : {},
|
|
12292
|
-
issues: r.issues.map((i) => ({
|
|
12293
|
-
severity: i.severity,
|
|
12294
|
-
category: i.category,
|
|
12295
|
-
stepId: i.stepId,
|
|
12296
|
-
message: i.message,
|
|
12297
|
-
...i.detail ? { detail: i.detail } : {}
|
|
12298
|
-
}))
|
|
12299
|
-
})) };
|
|
12300
|
-
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
11928
|
+
const parsed = PerspectivesSchema.safeParse(existingDoc);
|
|
11929
|
+
if (!parsed.success) {
|
|
11930
|
+
error("the hub document does not match the perspectives schema — run `ccqa perspectives` to regenerate it");
|
|
11931
|
+
process.exit(1);
|
|
11932
|
+
}
|
|
11933
|
+
info(`checking ${localCount} local test case(s) against the hub document...`);
|
|
11934
|
+
const issues = comparePerspectivesSkeleton(skeleton, parsed.data);
|
|
11935
|
+
if (issues.length === 0) {
|
|
11936
|
+
blank();
|
|
11937
|
+
info(`perspectives are up to date (${localCount} case(s)).`);
|
|
11938
|
+
return;
|
|
11939
|
+
}
|
|
11940
|
+
blank();
|
|
11941
|
+
for (const issue of issues) error(issue);
|
|
11942
|
+
blank();
|
|
11943
|
+
error(`perspectives are stale (${issues.length} issue(s)) — run \`ccqa perspectives\` to regenerate`);
|
|
11944
|
+
process.exit(1);
|
|
12301
11945
|
}
|
|
12302
|
-
|
|
12303
|
-
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
12308
|
-
|
|
11946
|
+
/**
|
|
11947
|
+
* Mechanical-field comparison behind `--check`. Returns one human-readable
|
|
11948
|
+
* line per out-of-sync spec (empty when in sync). Exported for unit testing.
|
|
11949
|
+
*/
|
|
11950
|
+
function comparePerspectivesSkeleton(local, remote) {
|
|
11951
|
+
const remoteMap = /* @__PURE__ */ new Map();
|
|
11952
|
+
for (const feature of remote.features) for (const spec of feature.specs) remoteMap.set(noteKey(feature.featureName, spec.specName), spec);
|
|
11953
|
+
const issues = [];
|
|
11954
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11955
|
+
for (const feature of local) for (const spec of feature.specs) {
|
|
11956
|
+
const key = noteKey(feature.featureName, spec.specName);
|
|
11957
|
+
seen.add(key);
|
|
11958
|
+
const remoteSpec = remoteMap.get(key);
|
|
11959
|
+
if (!remoteSpec) {
|
|
11960
|
+
issues.push(`${key}: not in the hub document`);
|
|
12309
11961
|
continue;
|
|
12310
11962
|
}
|
|
12311
|
-
|
|
12312
|
-
|
|
12313
|
-
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
11963
|
+
const fields = [];
|
|
11964
|
+
if (remoteSpec.title !== spec.title) fields.push("title");
|
|
11965
|
+
if (!sameStringArray(remoteSpec.relatedPaths, spec.relatedPaths)) fields.push("relatedPaths");
|
|
11966
|
+
if (remoteSpec.status.mode !== spec.status.mode || remoteSpec.status.traced !== spec.status.traced || remoteSpec.status.generated !== spec.status.generated) fields.push(`status (local: ${formatStatus(spec.status)}, hub: ${formatStatus(remoteSpec.status)})`);
|
|
11967
|
+
if (fields.length > 0) issues.push(`${key}: out of date — ${fields.join(", ")}`);
|
|
11968
|
+
}
|
|
11969
|
+
for (const key of remoteMap.keys()) if (!seen.has(key)) issues.push(`${key}: no longer exists locally (stale hub entry)`);
|
|
11970
|
+
return issues;
|
|
11971
|
+
}
|
|
11972
|
+
function formatStatus(status) {
|
|
11973
|
+
return `${status.mode}/traced=${status.traced}/generated=${status.generated}`;
|
|
11974
|
+
}
|
|
11975
|
+
/** Order-sensitive equality; an absent list and an empty list are the same thing. */
|
|
11976
|
+
function sameStringArray(a, b) {
|
|
11977
|
+
const left = a ?? [];
|
|
11978
|
+
const right = b ?? [];
|
|
11979
|
+
return left.length === right.length && left.every((v, i) => v === right[i]);
|
|
11980
|
+
}
|
|
11981
|
+
/** Perspectives live on the hub only — no hub, no place to store (or check) them. */
|
|
11982
|
+
function requireHubOrExit(opts) {
|
|
11983
|
+
try {
|
|
11984
|
+
return requireHubClient(opts);
|
|
11985
|
+
} catch (err) {
|
|
11986
|
+
if (err instanceof HubConnectionError) {
|
|
11987
|
+
error(err.message);
|
|
11988
|
+
hint("perspectives are stored on the hub — start one with `ccqa serve`");
|
|
11989
|
+
process.exit(2);
|
|
12317
11990
|
}
|
|
11991
|
+
throw err;
|
|
12318
11992
|
}
|
|
12319
|
-
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
12320
|
-
}
|
|
12321
|
-
function githubRelPath(cwd, repoRoot, featureName, specName) {
|
|
12322
|
-
const abs = resolve(cwd, ".ccqa", "features", featureName, "test-cases", specName, "spec.yaml");
|
|
12323
|
-
const rel = relative(repoRoot, abs);
|
|
12324
|
-
return rel.startsWith("..") ? abs : rel;
|
|
12325
|
-
}
|
|
12326
|
-
function escapeGhMessage(s) {
|
|
12327
|
-
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
12328
|
-
}
|
|
12329
|
-
function escapeGhProp(s) {
|
|
12330
|
-
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/,/g, "%2C").replace(/:/g, "%3A");
|
|
12331
11993
|
}
|
|
12332
|
-
function
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
12336
|
-
|
|
12337
|
-
|
|
12338
|
-
|
|
12339
|
-
|
|
12340
|
-
|
|
12341
|
-
else ok++;
|
|
11994
|
+
async function runPerspectives(opts) {
|
|
11995
|
+
const hub = requireHubOrExit(opts);
|
|
11996
|
+
const project = resolveProject(opts);
|
|
11997
|
+
header("perspectives", `project: ${project}`);
|
|
11998
|
+
const skeleton = await buildSkeleton(await listFeatureTree());
|
|
11999
|
+
const allSpecs = skeleton.flatMap((f) => f.specs);
|
|
12000
|
+
if (allSpecs.length === 0) {
|
|
12001
|
+
info("no test cases found under .ccqa/features — nothing to inventory.");
|
|
12002
|
+
return;
|
|
12342
12003
|
}
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12004
|
+
const existingDoc = await hub.getPerspectives(project);
|
|
12005
|
+
const noteMap = extractNotes(existingDoc);
|
|
12006
|
+
const specBodies = await loadSpecBodies(skeleton);
|
|
12007
|
+
meta("language", opts.language ?? "auto");
|
|
12008
|
+
info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
|
|
12009
|
+
const summaries = await requestSummaries(specBodies, opts);
|
|
12010
|
+
if (summaries === null) process.exit(1);
|
|
12011
|
+
const merged = mergePerspectives(skeleton, summaries, noteMap);
|
|
12012
|
+
let validated;
|
|
12013
|
+
try {
|
|
12014
|
+
validated = PerspectivesSchema.parse(merged);
|
|
12015
|
+
} catch (e) {
|
|
12016
|
+
error(`refused to push: assembled perspectives failed validation (${e.message})`);
|
|
12017
|
+
process.exit(1);
|
|
12018
|
+
}
|
|
12019
|
+
const existingYaml = existingDoc === null ? "" : stringify(existingDoc, { lineWidth: 0 });
|
|
12020
|
+
const next = stringify(validated, { lineWidth: 0 });
|
|
12021
|
+
if (withoutGeneratedAt(existingYaml) === withoutGeneratedAt(next)) {
|
|
12022
|
+
blank();
|
|
12023
|
+
info("perspectives already up to date — no changes.");
|
|
12024
|
+
await cleanupLegacyLocalFiles();
|
|
12025
|
+
return;
|
|
12026
|
+
}
|
|
12027
|
+
blank();
|
|
12028
|
+
info("--- proposed changes (YAML view of the hub document) ---");
|
|
12029
|
+
printUnifiedDiff(existingYaml, next);
|
|
12030
|
+
blank();
|
|
12031
|
+
if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "hub に perspectives を保存しますか? [y/N] " : "Push perspectives to the hub? [y/N] ")))) {
|
|
12032
|
+
info("aborted — no changes written.");
|
|
12033
|
+
return;
|
|
12034
|
+
}
|
|
12035
|
+
await hub.putPerspectives(project, validated);
|
|
12036
|
+
meta("pushed", `perspectives (project: ${project})`);
|
|
12037
|
+
await cleanupLegacyLocalFiles();
|
|
12349
12038
|
}
|
|
12350
|
-
//#endregion
|
|
12351
|
-
//#region src/drift/exit-code.ts
|
|
12352
12039
|
/**
|
|
12353
|
-
*
|
|
12354
|
-
*
|
|
12355
|
-
* the
|
|
12040
|
+
* Perspectives used to be written into the repo (`.ccqa/perspectives.yaml`,
|
|
12041
|
+
* `.ccqa/perspectives.md`, `.ccqa/features/<f>/perspectives.md`). Now that
|
|
12042
|
+
* the document is hub-only, sweep those leftovers whenever the command runs
|
|
12043
|
+
* so consuming repos converge without a manual cleanup.
|
|
12356
12044
|
*/
|
|
12357
|
-
function
|
|
12358
|
-
|
|
12359
|
-
|
|
12360
|
-
for (const issue of r.issues) {
|
|
12361
|
-
if (issue.severity === "ERROR") return 1;
|
|
12362
|
-
if (threshold === "warn" && issue.severity === "WARN") return 1;
|
|
12363
|
-
}
|
|
12364
|
-
}
|
|
12365
|
-
return 0;
|
|
12045
|
+
async function cleanupLegacyLocalFiles() {
|
|
12046
|
+
const removed = await removeLegacyPerspectivesFiles();
|
|
12047
|
+
for (const path of removed) meta("removed legacy file", path);
|
|
12366
12048
|
}
|
|
12367
12049
|
/**
|
|
12368
|
-
*
|
|
12369
|
-
*
|
|
12050
|
+
* Turn the feature tree into the skeleton perspectives features: title +
|
|
12051
|
+
* relatedPaths transcribed from each spec, status derived mechanically from
|
|
12052
|
+
* on-disk artifacts. `summary` is left empty here; Claude fills it later.
|
|
12053
|
+
* Specs whose spec.yaml is missing or unparsable are skipped.
|
|
12370
12054
|
*/
|
|
12371
|
-
function
|
|
12372
|
-
|
|
12373
|
-
|
|
12374
|
-
|
|
12375
|
-
|
|
12376
|
-
|
|
12377
|
-
|
|
12055
|
+
async function buildSkeleton(tree) {
|
|
12056
|
+
return (await Promise.all(tree.map(async (feature) => {
|
|
12057
|
+
const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
|
|
12058
|
+
const spec = await readSpecMeta(feature.featureName, s.specName);
|
|
12059
|
+
const status = await deriveStatus(feature.featureName, s.specName, spec.mode);
|
|
12060
|
+
const entry = {
|
|
12061
|
+
specName: s.specName,
|
|
12062
|
+
title: spec.title,
|
|
12063
|
+
summary: "",
|
|
12064
|
+
status
|
|
12065
|
+
};
|
|
12066
|
+
if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
|
|
12067
|
+
return entry;
|
|
12068
|
+
}));
|
|
12069
|
+
return {
|
|
12070
|
+
featureName: feature.featureName,
|
|
12071
|
+
specs
|
|
12072
|
+
};
|
|
12073
|
+
}))).filter((f) => f.specs.length > 0).map((f) => ({
|
|
12074
|
+
featureName: f.featureName,
|
|
12075
|
+
specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
|
|
12076
|
+
})).sort((a, b) => a.featureName.localeCompare(b.featureName));
|
|
12378
12077
|
}
|
|
12379
12078
|
/**
|
|
12380
|
-
*
|
|
12381
|
-
*
|
|
12382
|
-
*
|
|
12383
|
-
*
|
|
12079
|
+
* `(featureName, specName)` → human note, extracted from the hub's current
|
|
12080
|
+
* perspectives document. Notes are preserved across regeneration; everything
|
|
12081
|
+
* else (title, status, summary) is recomputed. Returns an empty map when the
|
|
12082
|
+
* document is absent or doesn't match the schema — note preservation is
|
|
12083
|
+
* best-effort and never blocks regeneration.
|
|
12384
12084
|
*/
|
|
12385
|
-
function
|
|
12386
|
-
const
|
|
12387
|
-
|
|
12388
|
-
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
12085
|
+
function extractNotes(existing) {
|
|
12086
|
+
const map = /* @__PURE__ */ new Map();
|
|
12087
|
+
if (existing === null || existing === void 0) return map;
|
|
12088
|
+
const result = PerspectivesSchema.safeParse(existing);
|
|
12089
|
+
if (!result.success) return map;
|
|
12090
|
+
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);
|
|
12091
|
+
return map;
|
|
12092
|
+
}
|
|
12093
|
+
/**
|
|
12094
|
+
* Merge the mechanical skeleton with Claude's summaries and the preserved
|
|
12095
|
+
* notes into the final perspectives object. Summaries are matched by
|
|
12096
|
+
* (featureName, specName); an unmatched spec keeps its empty summary.
|
|
12097
|
+
*/
|
|
12098
|
+
function mergePerspectives(skeleton, summaries, noteMap) {
|
|
12099
|
+
const summaryMap = /* @__PURE__ */ new Map();
|
|
12100
|
+
for (const s of summaries) summaryMap.set(noteKey(s.featureName, s.specName), s);
|
|
12101
|
+
const features = skeleton.map((feature) => ({
|
|
12102
|
+
featureName: feature.featureName,
|
|
12103
|
+
specs: feature.specs.map((spec) => {
|
|
12104
|
+
const key = noteKey(feature.featureName, spec.specName);
|
|
12105
|
+
const entry = summaryMap.get(key);
|
|
12106
|
+
const merged = {
|
|
12107
|
+
...spec,
|
|
12108
|
+
summary: entry?.summary ?? spec.summary
|
|
12109
|
+
};
|
|
12110
|
+
if (entry?.startScreen) merged.startScreen = entry.startScreen;
|
|
12111
|
+
if (entry?.testCondition) merged.testCondition = entry.testCondition;
|
|
12112
|
+
if (entry?.preconditions && entry.preconditions.length > 0) merged.preconditions = entry.preconditions;
|
|
12113
|
+
const note = noteMap.get(key);
|
|
12114
|
+
if (note !== void 0) merged.note = note;
|
|
12115
|
+
return merged;
|
|
12116
|
+
})
|
|
12402
12117
|
}));
|
|
12403
12118
|
return {
|
|
12404
|
-
|
|
12405
|
-
|
|
12406
|
-
createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
12407
|
-
runId: meta.runId ?? null,
|
|
12408
|
-
git: meta.git,
|
|
12409
|
-
model: meta.model ?? null,
|
|
12410
|
-
language: meta.language ?? null,
|
|
12411
|
-
promptVersion: meta.promptVersion ?? "1",
|
|
12412
|
-
customPromptVersion: null,
|
|
12413
|
-
results: specResults
|
|
12119
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12120
|
+
features
|
|
12414
12121
|
};
|
|
12415
12122
|
}
|
|
12416
|
-
//#endregion
|
|
12417
|
-
//#region src/drift/route-new-files.ts
|
|
12418
12123
|
/**
|
|
12419
|
-
*
|
|
12420
|
-
*
|
|
12421
|
-
* "
|
|
12422
|
-
*
|
|
12423
|
-
* Conservative by design — false positives are safer than false negatives,
|
|
12424
|
-
* because a missed spec turns into undetected drift in CI. When the router
|
|
12425
|
-
* call itself fails, we log a warning rather than fail-close: the surrounding
|
|
12426
|
-
* glob match is the primary signal; the router only adds coverage for new
|
|
12427
|
-
* paths no glob captures.
|
|
12124
|
+
* Strip the top-level `generatedAt:` line so two serialised perspectives can
|
|
12125
|
+
* be compared for substantive equality without the always-fresh timestamp
|
|
12126
|
+
* defeating the "already up to date" check. Exported for unit testing.
|
|
12428
12127
|
*/
|
|
12429
|
-
|
|
12430
|
-
|
|
12431
|
-
|
|
12432
|
-
|
|
12128
|
+
function withoutGeneratedAt(yamlText) {
|
|
12129
|
+
return yamlText.split("\n").filter((line) => !/^generatedAt:/.test(line)).join("\n").trim();
|
|
12130
|
+
}
|
|
12131
|
+
function noteKey(featureName, specName) {
|
|
12132
|
+
return `${featureName}/${specName}`;
|
|
12133
|
+
}
|
|
12134
|
+
async function readSpecMeta(featureName, specName) {
|
|
12135
|
+
const raw = await tryReadSpecFile(featureName, specName);
|
|
12136
|
+
if (raw === null) return {
|
|
12137
|
+
title: specName,
|
|
12138
|
+
mode: DEFAULT_SPEC_MODE
|
|
12139
|
+
};
|
|
12140
|
+
try {
|
|
12141
|
+
const parsed = parse(raw);
|
|
12142
|
+
const title = typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : specName;
|
|
12143
|
+
const modeResult = SpecModeSchema.safeParse(parsed.mode);
|
|
12144
|
+
return {
|
|
12145
|
+
title,
|
|
12146
|
+
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
|
|
12147
|
+
};
|
|
12148
|
+
} catch {
|
|
12149
|
+
return {
|
|
12150
|
+
title: specName,
|
|
12151
|
+
mode: DEFAULT_SPEC_MODE
|
|
12152
|
+
};
|
|
12153
|
+
}
|
|
12154
|
+
}
|
|
12155
|
+
async function deriveStatus(featureName, specName, mode) {
|
|
12156
|
+
return {
|
|
12157
|
+
mode,
|
|
12158
|
+
traced: await stat(join(getSpecDir(featureName, specName), "ir.json")).then(() => true).catch(() => false),
|
|
12159
|
+
generated: await getTestScript(featureName, specName) !== null
|
|
12160
|
+
};
|
|
12161
|
+
}
|
|
12162
|
+
async function loadSpecBodies(skeleton) {
|
|
12163
|
+
return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
|
|
12164
|
+
const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
|
|
12165
|
+
return {
|
|
12166
|
+
featureName: feature.featureName,
|
|
12167
|
+
specName: spec.specName,
|
|
12168
|
+
title: spec.title,
|
|
12169
|
+
specYaml
|
|
12170
|
+
};
|
|
12171
|
+
})));
|
|
12172
|
+
}
|
|
12173
|
+
async function requestSummaries(specs, opts) {
|
|
12174
|
+
const toolCounts = {};
|
|
12175
|
+
const startedAt = Date.now();
|
|
12433
12176
|
const { result, isError } = await invokeClaudeStreaming({
|
|
12434
|
-
prompt:
|
|
12435
|
-
|
|
12436
|
-
head: await readHead(join(cwd, path))
|
|
12437
|
-
}))), specs),
|
|
12438
|
-
systemPrompt: buildRouterSystemPrompt(),
|
|
12177
|
+
prompt: buildPerspectivesPrompt(specs, opts.instruction),
|
|
12178
|
+
systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
|
|
12439
12179
|
allowedTools: [
|
|
12440
12180
|
"Read",
|
|
12441
12181
|
"Grep",
|
|
12442
12182
|
"Glob"
|
|
12443
12183
|
],
|
|
12444
12184
|
silenceBashLog: true,
|
|
12445
|
-
|
|
12446
|
-
|
|
12447
|
-
|
|
12185
|
+
...opts.model ? { model: opts.model } : {}
|
|
12186
|
+
}, (msg) => {
|
|
12187
|
+
if (msg.type !== "assistant") return;
|
|
12188
|
+
for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
|
|
12189
|
+
});
|
|
12190
|
+
process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
|
|
12448
12191
|
if (isError) {
|
|
12449
|
-
|
|
12450
|
-
return
|
|
12192
|
+
error("Claude returned an error result");
|
|
12193
|
+
return null;
|
|
12451
12194
|
}
|
|
12452
12195
|
const json = extractJsonBlock(result);
|
|
12453
12196
|
if (!json) {
|
|
12454
|
-
|
|
12455
|
-
return
|
|
12197
|
+
error("Claude did not return a json block");
|
|
12198
|
+
return null;
|
|
12456
12199
|
}
|
|
12457
|
-
|
|
12200
|
+
return parseSummaries(json);
|
|
12201
|
+
}
|
|
12202
|
+
/**
|
|
12203
|
+
* Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
|
|
12204
|
+
* null and logs when the payload is malformed. Exported for unit testing.
|
|
12205
|
+
*/
|
|
12206
|
+
function parseSummaries(json) {
|
|
12207
|
+
let payload;
|
|
12458
12208
|
try {
|
|
12459
|
-
|
|
12209
|
+
payload = JSON.parse(json);
|
|
12460
12210
|
} catch (e) {
|
|
12461
|
-
|
|
12462
|
-
return
|
|
12211
|
+
error(`failed to parse summaries JSON: ${e.message}`);
|
|
12212
|
+
return null;
|
|
12463
12213
|
}
|
|
12464
|
-
|
|
12465
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
12468
|
-
|
|
12469
|
-
|
|
12214
|
+
if (typeof payload !== "object" || payload === null) {
|
|
12215
|
+
error("summaries payload is not an object");
|
|
12216
|
+
return null;
|
|
12217
|
+
}
|
|
12218
|
+
const summaries = payload.summaries;
|
|
12219
|
+
if (!Array.isArray(summaries)) {
|
|
12220
|
+
error("summaries payload missing a `summaries` array");
|
|
12221
|
+
return null;
|
|
12222
|
+
}
|
|
12223
|
+
const out = [];
|
|
12224
|
+
for (const item of summaries) {
|
|
12225
|
+
const rec = item ?? {};
|
|
12226
|
+
const { featureName, specName, summary } = rec;
|
|
12227
|
+
if (typeof featureName === "string" && typeof specName === "string" && typeof summary === "string") {
|
|
12228
|
+
const entry = {
|
|
12229
|
+
featureName,
|
|
12230
|
+
specName,
|
|
12231
|
+
summary
|
|
12232
|
+
};
|
|
12233
|
+
if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
|
|
12234
|
+
if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
|
|
12235
|
+
if (Array.isArray(rec.preconditions)) {
|
|
12236
|
+
const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
|
|
12237
|
+
if (pre.length > 0) entry.preconditions = pre;
|
|
12238
|
+
}
|
|
12239
|
+
out.push(entry);
|
|
12470
12240
|
}
|
|
12471
12241
|
}
|
|
12472
12242
|
return out;
|
|
12473
12243
|
}
|
|
12474
|
-
async function readHead(absPath) {
|
|
12475
|
-
const content = await readFile(absPath, "utf-8").catch(() => "");
|
|
12476
|
-
if (!content) return "";
|
|
12477
|
-
return content.split("\n").slice(0, 40).join("\n");
|
|
12478
|
-
}
|
|
12479
|
-
function buildRouterSystemPrompt() {
|
|
12480
|
-
return `You triage which ccqa test specs are potentially affected by NEW source files added in a pull request.
|
|
12481
|
-
|
|
12482
|
-
You will receive:
|
|
12483
|
-
- A list of new files (path + first ~40 lines of each)
|
|
12484
|
-
- A list of existing specs with their declared relatedPaths globs
|
|
12485
|
-
|
|
12486
|
-
Your job: return the spec keys (in "<feature>/<spec>" form) whose behaviour might depend on any of the new files.
|
|
12487
|
-
|
|
12488
|
-
## Rules
|
|
12489
|
-
|
|
12490
|
-
- Be **conservative**: when in doubt, include the spec. A spurious inclusion costs one extra drift check; a missed spec lets real drift slip through CI.
|
|
12491
|
-
- Use \`Read\`, \`Grep\`, \`Glob\` if you need to inspect the spec body or related code, but stay focused — this is a triage step, not a full review.
|
|
12492
|
-
- Ignore specs whose relatedPaths clearly point to a different area than every new file (e.g. \`src/auth/**\` specs vs new files only under \`src/billing/**\`).
|
|
12493
|
-
- Files like tests, generated code, build artifacts, vendor dirs typically do not affect any spec. Skip them.
|
|
12494
|
-
|
|
12495
|
-
## Output (STRICT)
|
|
12496
|
-
|
|
12497
|
-
Output ONE fenced \`\`\`json block, nothing else:
|
|
12498
|
-
|
|
12499
|
-
\`\`\`json
|
|
12500
|
-
{
|
|
12501
|
-
"affectedSpecs": ["feature/spec", "feature/spec"]
|
|
12502
|
-
}
|
|
12503
|
-
\`\`\`
|
|
12504
|
-
|
|
12505
|
-
Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
|
|
12506
|
-
`;
|
|
12507
|
-
}
|
|
12508
|
-
function buildRouterPrompt(previews, specs) {
|
|
12509
|
-
return `## New files
|
|
12510
|
-
|
|
12511
|
-
${previews.map((p) => {
|
|
12512
|
-
const headBlock = p.head ? `\n\`\`\`\n${p.head}\n\`\`\`` : "\n(empty or unreadable)";
|
|
12513
|
-
return `### ${p.path}${headBlock}`;
|
|
12514
|
-
}).join("\n\n")}
|
|
12515
|
-
|
|
12516
|
-
## Existing specs
|
|
12517
|
-
|
|
12518
|
-
${specs.map((s) => {
|
|
12519
|
-
const paths = s.relatedPaths.length === 0 ? " (no relatedPaths declared)" : s.relatedPaths.map((p) => ` - ${p}`).join("\n");
|
|
12520
|
-
return `- ${s.featureName}/${s.specName}\n${paths}`;
|
|
12521
|
-
}).join("\n")}
|
|
12522
|
-
|
|
12523
|
-
## Task
|
|
12524
|
-
|
|
12525
|
-
Return the spec keys that might be affected by any of the new files. Conservative inclusion is preferred over missing real drift.
|
|
12526
|
-
`;
|
|
12527
|
-
}
|
|
12528
12244
|
//#endregion
|
|
12529
|
-
//#region src/cli/
|
|
12530
|
-
const DEFAULT_CONCURRENCY = 3;
|
|
12531
|
-
const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").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.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(async (specPath, opts) => {
|
|
12532
|
-
const format = parseFormat(opts.format);
|
|
12533
|
-
const threshold = parseSeverity(opts.severity);
|
|
12534
|
-
const concurrency = parseConcurrency(opts.concurrency);
|
|
12535
|
-
const cwd = resolveCwd(opts.cwd);
|
|
12536
|
-
await ensureCcqaDir(cwd);
|
|
12537
|
-
if (opts.changed && specPath) {
|
|
12538
|
-
error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
|
|
12539
|
-
process.exit(2);
|
|
12540
|
-
}
|
|
12541
|
-
let targets = await collectTargets(specPath, cwd);
|
|
12542
|
-
if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
|
|
12543
|
-
if (format === "text") {
|
|
12544
|
-
header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
|
|
12545
|
-
if (opts.cwd) meta("cwd", cwd);
|
|
12546
|
-
}
|
|
12547
|
-
const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
|
|
12548
|
-
if (opts.changed) {
|
|
12549
|
-
const total = targets.length;
|
|
12550
|
-
targets = await filterByChanged({
|
|
12551
|
-
targets,
|
|
12552
|
-
cwd,
|
|
12553
|
-
baseOverride: opts.base,
|
|
12554
|
-
format,
|
|
12555
|
-
model: opts.model
|
|
12556
|
-
});
|
|
12557
|
-
if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
|
|
12558
|
-
if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
|
|
12559
|
-
}
|
|
12560
|
-
const blocks = await loadAvailableBlocks(cwd);
|
|
12561
|
-
const results = await analyzeDrift({
|
|
12562
|
-
targets,
|
|
12563
|
-
cwd,
|
|
12564
|
-
blocks,
|
|
12565
|
-
concurrency,
|
|
12566
|
-
...opts.model ? { model: opts.model } : {},
|
|
12567
|
-
...opts.language ? { language: opts.language } : {},
|
|
12568
|
-
onSpecStart: (t) => {
|
|
12569
|
-
if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
|
|
12570
|
-
}
|
|
12571
|
-
});
|
|
12572
|
-
process.stdout.write(renderDrift(results, format, cwd));
|
|
12573
|
-
if (opts.push) await pushDriftResults({
|
|
12574
|
-
results,
|
|
12575
|
-
threshold,
|
|
12576
|
-
cwd,
|
|
12577
|
-
opts,
|
|
12578
|
-
format,
|
|
12579
|
-
baseRef
|
|
12580
|
-
});
|
|
12581
|
-
process.exit(determineExitCode(results, threshold));
|
|
12582
|
-
});
|
|
12245
|
+
//#region src/cli/perspectives-sync.ts
|
|
12583
12246
|
/**
|
|
12584
|
-
*
|
|
12585
|
-
*
|
|
12586
|
-
*
|
|
12587
|
-
*
|
|
12247
|
+
* Best-effort incremental update of the hub-stored perspectives document
|
|
12248
|
+
* after a successful `ccqa record` / `ccqa generate`: upsert just this spec's
|
|
12249
|
+
* entry (mechanical facts recomputed, descriptive fields rewritten by one
|
|
12250
|
+
* small Claude call, the human `note` preserved). The full `ccqa perspectives`
|
|
12251
|
+
* run remains the way to regenerate everything and prune deleted specs.
|
|
12588
12252
|
*
|
|
12589
|
-
*
|
|
12590
|
-
* a real hub connection; it defaults to the real flag/env resolution.
|
|
12253
|
+
* Never fails the caller: no hub configured → no-op; any error → warning.
|
|
12591
12254
|
*/
|
|
12592
|
-
async function
|
|
12593
|
-
|
|
12594
|
-
const hub = resolveHub(opts);
|
|
12595
|
-
if (!hub) {
|
|
12596
|
-
warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
|
|
12597
|
-
return;
|
|
12598
|
-
}
|
|
12255
|
+
async function syncSpecPerspectives(hubContext, opts) {
|
|
12256
|
+
if (!hubContext) return;
|
|
12599
12257
|
try {
|
|
12600
|
-
|
|
12601
|
-
project: opts.project,
|
|
12602
|
-
cwd
|
|
12603
|
-
});
|
|
12604
|
-
const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
12605
|
-
const report = driftResultsToReport(results, {
|
|
12606
|
-
threshold,
|
|
12607
|
-
git: {
|
|
12608
|
-
head,
|
|
12609
|
-
base: baseRef ?? null
|
|
12610
|
-
}
|
|
12611
|
-
});
|
|
12612
|
-
const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
|
|
12613
|
-
try {
|
|
12614
|
-
await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
|
|
12615
|
-
const archive = await packDirToTarGz(dir);
|
|
12616
|
-
const run = await hub.pushRun(archive, {
|
|
12617
|
-
project,
|
|
12618
|
-
...branch ? { branch } : {},
|
|
12619
|
-
kind: "drift"
|
|
12620
|
-
});
|
|
12621
|
-
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
|
|
12622
|
-
} finally {
|
|
12623
|
-
await rm(dir, {
|
|
12624
|
-
recursive: true,
|
|
12625
|
-
force: true
|
|
12626
|
-
});
|
|
12627
|
-
}
|
|
12258
|
+
await doSync(hubContext, opts);
|
|
12628
12259
|
} catch (err) {
|
|
12629
|
-
|
|
12630
|
-
error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
|
|
12631
|
-
process.exit(2);
|
|
12632
|
-
}
|
|
12633
|
-
throw err;
|
|
12260
|
+
warn(`perspectives auto-update skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
12634
12261
|
}
|
|
12635
12262
|
}
|
|
12636
|
-
function
|
|
12637
|
-
|
|
12638
|
-
|
|
12639
|
-
|
|
12263
|
+
async function doSync(ctx, opts) {
|
|
12264
|
+
const { featureName, specName } = opts.ref;
|
|
12265
|
+
const specYaml = await tryReadSpecFile(featureName, specName);
|
|
12266
|
+
if (specYaml === null) return;
|
|
12267
|
+
const existing = await ctx.hub.getPerspectives(ctx.project);
|
|
12268
|
+
let doc;
|
|
12269
|
+
if (existing === null) doc = {
|
|
12270
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
12271
|
+
features: []
|
|
12272
|
+
};
|
|
12273
|
+
else {
|
|
12274
|
+
const parsed = PerspectivesSchema.safeParse(existing);
|
|
12275
|
+
if (!parsed.success) {
|
|
12276
|
+
warn("perspectives auto-update skipped: the hub document does not match the schema — run `ccqa perspectives` to regenerate it");
|
|
12277
|
+
return;
|
|
12278
|
+
}
|
|
12279
|
+
doc = parsed.data;
|
|
12280
|
+
}
|
|
12281
|
+
const meta$1 = await readSpecMeta(featureName, specName);
|
|
12282
|
+
const status = await deriveStatus(featureName, specName, meta$1.mode);
|
|
12283
|
+
const relatedPaths = extractRelatedPaths(specYaml);
|
|
12284
|
+
const previous = findSpec(doc, featureName, specName);
|
|
12285
|
+
const written = (await requestSummaries([{
|
|
12286
|
+
featureName,
|
|
12287
|
+
specName,
|
|
12288
|
+
title: meta$1.title,
|
|
12289
|
+
specYaml
|
|
12290
|
+
}], {
|
|
12291
|
+
...opts.language ? { language: opts.language } : {},
|
|
12292
|
+
...opts.model ? { model: opts.model } : {}
|
|
12293
|
+
}))?.[0];
|
|
12294
|
+
const entry = {
|
|
12295
|
+
specName,
|
|
12296
|
+
title: meta$1.title,
|
|
12297
|
+
summary: written?.summary ?? previous?.summary ?? "",
|
|
12298
|
+
status
|
|
12299
|
+
};
|
|
12300
|
+
const startScreen = written?.startScreen ?? previous?.startScreen;
|
|
12301
|
+
if (startScreen) entry.startScreen = startScreen;
|
|
12302
|
+
const testCondition = written?.testCondition ?? previous?.testCondition;
|
|
12303
|
+
if (testCondition) entry.testCondition = testCondition;
|
|
12304
|
+
const preconditions = written?.preconditions ?? previous?.preconditions;
|
|
12305
|
+
if (preconditions && preconditions.length > 0) entry.preconditions = preconditions;
|
|
12306
|
+
if (relatedPaths.length > 0) entry.relatedPaths = relatedPaths;
|
|
12307
|
+
if (previous?.note) entry.note = previous.note;
|
|
12308
|
+
upsertSpec(doc, featureName, entry);
|
|
12309
|
+
doc.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
12310
|
+
await ctx.hub.putPerspectives(ctx.project, PerspectivesSchema.parse(doc));
|
|
12311
|
+
meta("perspectives", `updated ${featureName}/${specName} on the hub`);
|
|
12312
|
+
}
|
|
12313
|
+
function findSpec(doc, featureName, specName) {
|
|
12314
|
+
return doc.features.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
|
|
12315
|
+
}
|
|
12316
|
+
/** Replace-or-insert the spec entry, keeping features and specs name-sorted like a full regeneration. */
|
|
12317
|
+
function upsertSpec(doc, featureName, entry) {
|
|
12318
|
+
let feature = doc.features.find((f) => f.featureName === featureName);
|
|
12319
|
+
if (!feature) {
|
|
12320
|
+
feature = {
|
|
12321
|
+
featureName,
|
|
12322
|
+
specs: []
|
|
12323
|
+
};
|
|
12324
|
+
doc.features.push(feature);
|
|
12325
|
+
doc.features.sort((a, b) => a.featureName.localeCompare(b.featureName));
|
|
12326
|
+
}
|
|
12327
|
+
const idx = feature.specs.findIndex((s) => s.specName === entry.specName);
|
|
12328
|
+
if (idx >= 0) feature.specs[idx] = entry;
|
|
12329
|
+
else {
|
|
12330
|
+
feature.specs.push(entry);
|
|
12331
|
+
feature.specs.sort((a, b) => a.specName.localeCompare(b.specName));
|
|
12332
|
+
}
|
|
12640
12333
|
}
|
|
12641
|
-
|
|
12642
|
-
|
|
12643
|
-
const base = resolveBaseRef(baseOverride);
|
|
12644
|
-
let changed;
|
|
12334
|
+
/** `relatedPaths` transcribed verbatim from the spec.yaml (empty when absent/unparsable). */
|
|
12335
|
+
function extractRelatedPaths(specYaml) {
|
|
12645
12336
|
try {
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
|
|
12649
|
-
|
|
12337
|
+
const parsed = parse(specYaml);
|
|
12338
|
+
if (!Array.isArray(parsed?.relatedPaths)) return [];
|
|
12339
|
+
return parsed.relatedPaths.filter((p) => typeof p === "string" && p.length > 0);
|
|
12340
|
+
} catch {
|
|
12341
|
+
return [];
|
|
12650
12342
|
}
|
|
12651
|
-
|
|
12652
|
-
|
|
12653
|
-
|
|
12343
|
+
}
|
|
12344
|
+
//#endregion
|
|
12345
|
+
//#region src/cli/generate.ts
|
|
12346
|
+
const AUTO_FIX_MODES = [
|
|
12347
|
+
"interactive",
|
|
12348
|
+
"auto",
|
|
12349
|
+
"skip"
|
|
12350
|
+
];
|
|
12351
|
+
function toFixMode(autoFix) {
|
|
12352
|
+
switch (autoFix) {
|
|
12353
|
+
case "auto": return "auto";
|
|
12354
|
+
case "skip": return "non-interactive";
|
|
12355
|
+
case "interactive": return "interactive";
|
|
12654
12356
|
}
|
|
12655
|
-
|
|
12656
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
12659
|
-
|
|
12660
|
-
|
|
12661
|
-
|
|
12662
|
-
|
|
12663
|
-
|
|
12357
|
+
}
|
|
12358
|
+
/** Shared `--auto-fix` parser for the record / generate commands. */
|
|
12359
|
+
function parseAutoFixFlag(raw) {
|
|
12360
|
+
if (AUTO_FIX_MODES.includes(raw)) return raw;
|
|
12361
|
+
throw new Error(`--auto-fix must be one of ${AUTO_FIX_MODES.join(" | ")}`);
|
|
12362
|
+
}
|
|
12363
|
+
/**
|
|
12364
|
+
* The `generate` flow shared by `ccqa generate` and the codegen half of
|
|
12365
|
+
* `ccqa record`: resolve the spec's target plugin, load its input (the
|
|
12366
|
+
* recording, for input:"recording" targets), and dispatch to the plugin.
|
|
12367
|
+
* This layer owns the CLI concerns — overwrite confirmation, logging,
|
|
12368
|
+
* exit-code policy — while the plugin owns the generation pipeline.
|
|
12369
|
+
*/
|
|
12370
|
+
async function runGenerate(featureName, specName, opts) {
|
|
12371
|
+
header("generate", `${featureName}/${specName}`);
|
|
12372
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
12373
|
+
await ensureCcqaDir(cwd);
|
|
12374
|
+
const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
|
|
12375
|
+
try {
|
|
12376
|
+
await runGenerateLocked(featureName, specName, opts, cwd);
|
|
12377
|
+
} finally {
|
|
12378
|
+
await releaseLock();
|
|
12664
12379
|
}
|
|
12665
|
-
|
|
12666
|
-
|
|
12667
|
-
|
|
12668
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12671
|
-
|
|
12672
|
-
|
|
12380
|
+
}
|
|
12381
|
+
async function runGenerateLocked(featureName, specName, opts, cwd) {
|
|
12382
|
+
const specYaml = await readSpecFile(featureName, specName, cwd);
|
|
12383
|
+
const spec = parseTestSpec(specYaml);
|
|
12384
|
+
const config = await loadProjectConfig(cwd);
|
|
12385
|
+
const target = opts.targetOverride !== void 0 ? resolveTargetOverride(spec, opts.targetOverride) : resolveTarget(spec, config);
|
|
12386
|
+
meta("target", target.id + (opts.targetOverride !== void 0 ? " (--target override)" : ""));
|
|
12387
|
+
const existingOutput = await target.existingOutput?.({
|
|
12388
|
+
featureName,
|
|
12389
|
+
specName
|
|
12390
|
+
}, cwd) ?? null;
|
|
12391
|
+
if (existingOutput && !opts.force) {
|
|
12392
|
+
if (!await confirmOverwrite(existingOutput)) {
|
|
12393
|
+
info("aborted; pass --force to overwrite without prompting");
|
|
12394
|
+
return;
|
|
12673
12395
|
}
|
|
12674
|
-
if (t.includedBlocks?.some((name) => touchedBlockNames.has(name))) affected.add(specKey(t));
|
|
12675
12396
|
}
|
|
12676
|
-
|
|
12677
|
-
|
|
12678
|
-
const
|
|
12679
|
-
|
|
12680
|
-
|
|
12681
|
-
|
|
12682
|
-
specName: t.specName,
|
|
12683
|
-
relatedPaths: t.relatedPaths
|
|
12684
|
-
})),
|
|
12685
|
-
cwd,
|
|
12686
|
-
model
|
|
12687
|
-
});
|
|
12688
|
-
for (const key of routed) affected.add(key);
|
|
12397
|
+
let recording;
|
|
12398
|
+
if (target.input === "recording") {
|
|
12399
|
+
const { path: recordingPath, actions } = await getRecording(featureName, specName, cwd);
|
|
12400
|
+
meta("recording", recordingPath);
|
|
12401
|
+
meta("actions", actions.length);
|
|
12402
|
+
recording = actions;
|
|
12689
12403
|
}
|
|
12690
|
-
|
|
12691
|
-
}
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12696
|
-
|
|
12697
|
-
|
|
12698
|
-
|
|
12699
|
-
|
|
12404
|
+
await warnStaleBlockArtifacts();
|
|
12405
|
+
const targetConfig = config.targets[target.id] ?? TargetConfigSchema.parse({});
|
|
12406
|
+
const ctx = {
|
|
12407
|
+
spec,
|
|
12408
|
+
specYaml,
|
|
12409
|
+
featureName,
|
|
12410
|
+
specName,
|
|
12411
|
+
cwd,
|
|
12412
|
+
recording,
|
|
12413
|
+
resources: targetConfig.resources,
|
|
12414
|
+
conventions: targetConfig.conventions,
|
|
12415
|
+
targetConfig,
|
|
12416
|
+
language: opts.language,
|
|
12417
|
+
model: opts.model,
|
|
12418
|
+
hub: opts.hubContext ?? null,
|
|
12419
|
+
fix: {
|
|
12420
|
+
maxRetries: opts.maxRetries,
|
|
12421
|
+
mode: opts.fixMode,
|
|
12422
|
+
useSnapshot: opts.useSnapshot
|
|
12700
12423
|
}
|
|
12701
|
-
|
|
12702
|
-
|
|
12703
|
-
|
|
12704
|
-
|
|
12705
|
-
}];
|
|
12706
|
-
}
|
|
12707
|
-
const out = [];
|
|
12708
|
-
for (const feature of tree) for (const spec of feature.specs) {
|
|
12709
|
-
if (!spec.hasSpecFile) continue;
|
|
12710
|
-
const t = {
|
|
12711
|
-
featureName: feature.featureName,
|
|
12712
|
-
specName: spec.specName
|
|
12713
|
-
};
|
|
12714
|
-
if (spec.relatedPaths) t.relatedPaths = spec.relatedPaths;
|
|
12715
|
-
if (spec.includedBlocks) t.includedBlocks = spec.includedBlocks;
|
|
12716
|
-
out.push(t);
|
|
12424
|
+
};
|
|
12425
|
+
if (!(await target.generate(ctx)).passed) {
|
|
12426
|
+
warn("auto-fix exhausted; test still failing");
|
|
12427
|
+
process.exit(1);
|
|
12717
12428
|
}
|
|
12718
|
-
|
|
12719
|
-
}
|
|
12720
|
-
function parseFormat(raw) {
|
|
12721
|
-
const v = raw ?? "text";
|
|
12722
|
-
if (v === "text" || v === "json" || v === "github") return v;
|
|
12723
|
-
error(`invalid --format: ${v} (expected text|json|github)`);
|
|
12724
|
-
process.exit(2);
|
|
12725
|
-
}
|
|
12726
|
-
function parseSeverity(raw) {
|
|
12727
|
-
const v = raw ?? "error";
|
|
12728
|
-
if (v === "warn" || v === "error") return v;
|
|
12729
|
-
error(`invalid --severity: ${v} (expected warn|error)`);
|
|
12730
|
-
process.exit(2);
|
|
12429
|
+
hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
|
|
12731
12430
|
}
|
|
12732
|
-
function
|
|
12733
|
-
if (
|
|
12734
|
-
|
|
12735
|
-
|
|
12736
|
-
|
|
12737
|
-
|
|
12431
|
+
async function confirmOverwrite(path) {
|
|
12432
|
+
if (!process.stdin.isTTY) {
|
|
12433
|
+
warn(`${path} exists and stdin is not a TTY; refusing to overwrite. Pass --force to allow.`);
|
|
12434
|
+
return false;
|
|
12435
|
+
}
|
|
12436
|
+
const rl = createInterface({
|
|
12437
|
+
input: process.stdin,
|
|
12438
|
+
output: process.stdout
|
|
12439
|
+
});
|
|
12440
|
+
try {
|
|
12441
|
+
process.stdout.write("\n");
|
|
12442
|
+
process.stdout.write(`[warn] ${path} already exists.\n`);
|
|
12443
|
+
process.stdout.write(`[warn] generate will regenerate it and any manual edits will be lost.\n`);
|
|
12444
|
+
const norm = (await new Promise((res) => rl.question("Overwrite? [y/N] ", res))).trim().toLowerCase();
|
|
12445
|
+
return norm === "y" || norm === "yes";
|
|
12446
|
+
} finally {
|
|
12447
|
+
rl.close();
|
|
12738
12448
|
}
|
|
12739
|
-
return n;
|
|
12740
12449
|
}
|
|
12741
|
-
|
|
12742
|
-
|
|
12743
|
-
const
|
|
12450
|
+
const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N), 'auto' (apply without prompt, for CI), 'skip' (never prompt, only apply high-confidence fixes).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite previously generated test code without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (specPath, opts) => {
|
|
12451
|
+
const { featureName, specName } = parseSpecPath(specPath);
|
|
12452
|
+
const language = opts.language ?? "auto";
|
|
12744
12453
|
const cwd = resolveCwd(opts.cwd);
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
|
|
12748
|
-
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12454
|
+
const hubClient = resolveHubClient({
|
|
12455
|
+
hubUrl: opts.hubUrl,
|
|
12456
|
+
hubToken: opts.hubToken,
|
|
12457
|
+
hubHeader: opts.hubHeader
|
|
12458
|
+
});
|
|
12459
|
+
const project = opts.profile !== void 0 || hubClient !== null ? resolveProject(opts) : void 0;
|
|
12460
|
+
if (opts.profile !== void 0) await applyProfileFromOption({
|
|
12461
|
+
profile: opts.profile,
|
|
12462
|
+
project,
|
|
12463
|
+
cwd,
|
|
12464
|
+
hubUrl: opts.hubUrl,
|
|
12465
|
+
hubToken: opts.hubToken,
|
|
12466
|
+
hubHeader: opts.hubHeader
|
|
12467
|
+
});
|
|
12468
|
+
else await applyProfileFromOption({
|
|
12469
|
+
profile: void 0,
|
|
12470
|
+
project: "",
|
|
12471
|
+
cwd
|
|
12472
|
+
});
|
|
12473
|
+
const hubContext = hubClient && project ? {
|
|
12474
|
+
hub: hubClient,
|
|
12475
|
+
project
|
|
12476
|
+
} : null;
|
|
12477
|
+
try {
|
|
12478
|
+
await runGenerate(featureName, specName, {
|
|
12479
|
+
maxRetries: parseInt(opts.maxRetries ?? "3", 10),
|
|
12480
|
+
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
12481
|
+
force: opts.force ?? false,
|
|
12482
|
+
useSnapshot: opts.snapshot !== false,
|
|
12483
|
+
language,
|
|
12484
|
+
model: opts.model,
|
|
12485
|
+
targetOverride: opts.target,
|
|
12486
|
+
cwd,
|
|
12487
|
+
hubContext
|
|
12488
|
+
});
|
|
12489
|
+
} catch (e) {
|
|
12490
|
+
if (e instanceof SpecLockedError) {
|
|
12491
|
+
error(e.message);
|
|
12492
|
+
process.exit(2);
|
|
12493
|
+
}
|
|
12494
|
+
throw e;
|
|
12495
|
+
}
|
|
12496
|
+
await syncSpecPerspectives(hubContext, {
|
|
12497
|
+
ref: {
|
|
12498
|
+
featureName,
|
|
12499
|
+
specName
|
|
12500
|
+
},
|
|
12501
|
+
...language ? { language } : {},
|
|
12502
|
+
...opts.model ? { model: opts.model } : {}
|
|
12503
|
+
});
|
|
12504
|
+
});
|
|
12505
|
+
//#endregion
|
|
12506
|
+
//#region src/cli/record.ts
|
|
12507
|
+
const VALIDATION_MODES = ["lenient", "strict"];
|
|
12508
|
+
const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a deterministic test from a spec: run agent-browser to collect actions (trace), then generate test.spec.ts with auto-fix retries (generate). After recording, `ccqa run <feature/spec>` replays it under vitest (deterministic specs only — live specs do not need recording).").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; 'strict' drops them.", (raw) => {
|
|
12509
|
+
if (VALIDATION_MODES.includes(raw)) return raw;
|
|
12510
|
+
throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
|
|
12511
|
+
}, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N), 'auto' (apply without prompt, for CI), 'skip' (never prompt, only apply high-confidence fixes).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").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("--skip-trace", "Skip the trace step and run codegen against an existing ir.json").option("--skip-codegen", "Run only the trace step (do not generate test.spec.ts)").option("--update-agent-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (specPath, opts) => {
|
|
12512
|
+
const { featureName, specName } = parseSpecPath(specPath);
|
|
12513
|
+
const language = opts.language ?? "auto";
|
|
12514
|
+
if (opts.skipTrace && opts.skipCodegen) {
|
|
12515
|
+
error("--skip-trace and --skip-codegen cannot be combined; nothing would run");
|
|
12516
|
+
process.exit(2);
|
|
12517
|
+
}
|
|
12518
|
+
const cwdForProfile = resolveCwd(opts.cwd);
|
|
12519
|
+
const target = resolveTarget(parseTestSpec(await readSpecFile(featureName, specName, cwdForProfile)), await loadProjectConfig(cwdForProfile));
|
|
12520
|
+
if (target.input === "spec") {
|
|
12521
|
+
error(`target "${target.id}" does not use a browser recording — run 'ccqa generate ${featureName}/${specName}' instead`);
|
|
12522
|
+
process.exit(2);
|
|
12523
|
+
}
|
|
12524
|
+
const project = opts.profile !== void 0 ? resolveProject(opts) : void 0;
|
|
12525
|
+
if (opts.profile !== void 0) await applyProfileFromOption({
|
|
12526
|
+
profile: opts.profile,
|
|
12527
|
+
project,
|
|
12528
|
+
cwd: cwdForProfile,
|
|
12529
|
+
hubUrl: opts.hubUrl,
|
|
12530
|
+
hubToken: opts.hubToken,
|
|
12531
|
+
hubHeader: opts.hubHeader
|
|
12532
|
+
});
|
|
12533
|
+
else await applyProfileFromOption({
|
|
12534
|
+
profile: void 0,
|
|
12535
|
+
project: "",
|
|
12536
|
+
cwd: cwdForProfile
|
|
12537
|
+
});
|
|
12538
|
+
const hubClientForTrace = resolveHubClient({
|
|
12539
|
+
hubUrl: opts.hubUrl,
|
|
12540
|
+
hubToken: opts.hubToken,
|
|
12541
|
+
hubHeader: opts.hubHeader
|
|
12542
|
+
});
|
|
12543
|
+
const hubProject = project ?? (hubClientForTrace !== null ? resolveProject(opts) : void 0);
|
|
12544
|
+
const hubContext = hubClientForTrace && hubProject ? {
|
|
12545
|
+
hub: hubClientForTrace,
|
|
12546
|
+
project: hubProject
|
|
12547
|
+
} : null;
|
|
12548
|
+
const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
|
|
12549
|
+
if (e instanceof SpecLockedError) {
|
|
12550
|
+
error(e.message);
|
|
12551
|
+
process.exit(2);
|
|
12552
|
+
}
|
|
12553
|
+
throw e;
|
|
12554
|
+
});
|
|
12555
|
+
let traceResult = null;
|
|
12556
|
+
try {
|
|
12557
|
+
if (!opts.skipTrace) {
|
|
12558
|
+
traceResult = await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", language, {
|
|
12559
|
+
cwd: cwdForProfile,
|
|
12560
|
+
hubContext
|
|
12561
|
+
});
|
|
12562
|
+
blank();
|
|
12563
|
+
}
|
|
12564
|
+
if (!opts.skipCodegen) await runGenerate(featureName, specName, {
|
|
12565
|
+
maxRetries: parseInt(opts.maxRetries ?? "3", 10),
|
|
12566
|
+
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
12567
|
+
force: opts.force ?? false,
|
|
12568
|
+
useSnapshot: opts.snapshot !== false,
|
|
12569
|
+
language,
|
|
12570
|
+
model: opts.model,
|
|
12571
|
+
cwd: cwdForProfile,
|
|
12572
|
+
hubContext
|
|
12573
|
+
});
|
|
12574
|
+
} finally {
|
|
12575
|
+
await releaseLock();
|
|
12576
|
+
}
|
|
12577
|
+
if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
|
|
12578
|
+
else {
|
|
12579
|
+
blank();
|
|
12580
|
+
await updateAgentPrompt({
|
|
12581
|
+
mode: "record",
|
|
12582
|
+
runSummary: buildRecordRunSummary(featureName, specName, traceResult),
|
|
12583
|
+
hubContext,
|
|
12584
|
+
...opts.model ? { model: opts.model } : {},
|
|
12585
|
+
...language ? { language } : {}
|
|
12586
|
+
});
|
|
12587
|
+
}
|
|
12588
|
+
await syncSpecPerspectives(hubContext, {
|
|
12589
|
+
ref: {
|
|
12590
|
+
featureName,
|
|
12591
|
+
specName
|
|
12592
|
+
},
|
|
12593
|
+
...language ? { language } : {},
|
|
12594
|
+
...opts.model ? { model: opts.model } : {}
|
|
12595
|
+
});
|
|
12596
|
+
});
|
|
12755
12597
|
/**
|
|
12756
|
-
*
|
|
12757
|
-
*
|
|
12758
|
-
*
|
|
12759
|
-
*
|
|
12598
|
+
* Compact summary of the trace pass for the record agent-prompt refresh.
|
|
12599
|
+
* Steps are reconstructed from the trace's status-line protocol (STEP_START
|
|
12600
|
+
* gives the title, STEP_DONE / ASSERTION_FAILED / STEP_SKIPPED the outcome),
|
|
12601
|
+
* and each step carries its per-action observations plus the **concrete kept
|
|
12602
|
+
* commands** — the selectors that actually survived scrub / dedup /
|
|
12603
|
+
* validation. The record playbook is told to record canonical selectors, so
|
|
12604
|
+
* it needs those exact tokens; the prose alone doesn't carry them. The
|
|
12605
|
+
* header's kept/recorded totals flag how much the run thrashed through
|
|
12606
|
+
* selectors overall.
|
|
12760
12607
|
*/
|
|
12761
|
-
function
|
|
12762
|
-
|
|
12763
|
-
|
|
12764
|
-
|
|
12765
|
-
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
12769
|
-
|
|
12770
|
-
|
|
12771
|
-
|
|
12772
|
-
|
|
12773
|
-
|
|
12774
|
-
|
|
12775
|
-
|
|
12776
|
-
|
|
12777
|
-
|
|
12778
|
-
|
|
12779
|
-
|
|
12780
|
-
|
|
12781
|
-
|
|
12782
|
-
|
|
12783
|
-
|
|
12784
|
-
|
|
12785
|
-
|
|
12786
|
-
|
|
12787
|
-
|
|
12788
|
-
|
|
12789
|
-
|
|
12790
|
-
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
12795
|
-
{
|
|
12796
|
-
|
|
12797
|
-
|
|
12798
|
-
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
12805
|
-
|
|
12608
|
+
function buildRecordRunSummary(featureName, specName, t) {
|
|
12609
|
+
const header = `## ${featureName}/${specName} — ${t.status}\nActions: ${t.actionsKept} kept / ${t.actionsRecorded} recorded`;
|
|
12610
|
+
const steps = collectStepSummaries(t.statusLines);
|
|
12611
|
+
if (steps.length === 0) return `${header}\n\n(no step status lines recorded)`;
|
|
12612
|
+
const commandsByStep = groupCommandsByStep(t.actions);
|
|
12613
|
+
const observationsByStep = groupObservationsByStep(t.actions);
|
|
12614
|
+
const unstableByStep = countUnstableByStep(t.actions);
|
|
12615
|
+
return `${header}\n\n${steps.map((s) => {
|
|
12616
|
+
const cmds = commandsByStep.get(s.stepId) ?? [];
|
|
12617
|
+
const observations = observationsByStep.get(s.stepId) ?? [];
|
|
12618
|
+
const churn = t.churnByStep.get(s.stepId);
|
|
12619
|
+
const dropped = churn ? churn.recorded - churn.kept : 0;
|
|
12620
|
+
const redundant = churn?.redundant ?? 0;
|
|
12621
|
+
const unstable = unstableByStep.get(s.stepId) ?? 0;
|
|
12622
|
+
return [
|
|
12623
|
+
`### ${s.stepId} — ${oneLineSummary(s.title)} (${s.status})`,
|
|
12624
|
+
...s.detail ? [`- result: ${oneLineSummary(s.detail)}`] : [],
|
|
12625
|
+
...observations.length > 0 ? [`- observations: ${observations.map(oneLineSummary).join(" ; ")}`] : [],
|
|
12626
|
+
...dropped > 0 ? [`- churn: ${churn.recorded} attempts → ${churn.kept} kept (${dropped} dropped)`] : [],
|
|
12627
|
+
...redundant > 0 ? [`- redundant: ${redundant} field(s) entered via 2+ selectors (kept both — record one canonical selector)`] : [],
|
|
12628
|
+
...unstable > 0 ? [`- replay-unstable: ${unstable} kept command(s) marked [unstable] — do NOT record these as canonical; prefer a more stable locator even if it costs one more probe`] : [],
|
|
12629
|
+
...cmds.length > 0 ? [`- kept commands: ${cmds.join(" ; ")}`] : []
|
|
12630
|
+
].join("\n");
|
|
12631
|
+
}).join("\n\n")}`;
|
|
12632
|
+
}
|
|
12633
|
+
/**
|
|
12634
|
+
* Fold the ordered status lines into one entry per step: STEP_START opens
|
|
12635
|
+
* the entry (its detail is the step title); the terminal line sets the
|
|
12636
|
+
* outcome. A terminal line for a step that never emitted STEP_START still
|
|
12637
|
+
* gets an entry so its outcome isn't silently lost.
|
|
12638
|
+
*/
|
|
12639
|
+
function collectStepSummaries(lines) {
|
|
12640
|
+
const byId = /* @__PURE__ */ new Map();
|
|
12641
|
+
const ordered = [];
|
|
12642
|
+
const ensure = (stepId, title) => {
|
|
12643
|
+
let entry = byId.get(stepId);
|
|
12644
|
+
if (!entry) {
|
|
12645
|
+
entry = {
|
|
12646
|
+
stepId,
|
|
12647
|
+
title,
|
|
12648
|
+
status: "NO_STATUS"
|
|
12649
|
+
};
|
|
12650
|
+
byId.set(stepId, entry);
|
|
12651
|
+
ordered.push(entry);
|
|
12652
|
+
}
|
|
12653
|
+
return entry;
|
|
12654
|
+
};
|
|
12655
|
+
for (const line of lines) {
|
|
12656
|
+
if (!line.stepId || line.type === "RUN_COMPLETED") continue;
|
|
12657
|
+
if (line.type === "STEP_START") {
|
|
12658
|
+
ensure(line.stepId, line.detail);
|
|
12659
|
+
continue;
|
|
12660
|
+
}
|
|
12661
|
+
const entry = ensure(line.stepId, "(untitled)");
|
|
12662
|
+
entry.status = line.type === "STEP_DONE" ? "DONE" : line.type === "ASSERTION_FAILED" ? "FAILED" : "SKIPPED";
|
|
12663
|
+
if (line.detail) entry.detail = line.detail;
|
|
12664
|
+
}
|
|
12665
|
+
return ordered;
|
|
12666
|
+
}
|
|
12667
|
+
/** Group each kept action's `action selector` form under its stepId. */
|
|
12668
|
+
function groupCommandsByStep(actions) {
|
|
12669
|
+
const byStep = /* @__PURE__ */ new Map();
|
|
12670
|
+
for (const a of actions) {
|
|
12671
|
+
if (!a.stepId) continue;
|
|
12672
|
+
const list = byStep.get(a.stepId) ?? [];
|
|
12673
|
+
list.push(formatRecordedAction(a));
|
|
12674
|
+
byStep.set(a.stepId, list);
|
|
12675
|
+
}
|
|
12676
|
+
return byStep;
|
|
12677
|
+
}
|
|
12678
|
+
/**
|
|
12679
|
+
* Group per-action observations (snapshot / assert prose) under their stepId
|
|
12680
|
+
* — the trace's own record of what it verified at each step.
|
|
12681
|
+
*/
|
|
12682
|
+
function groupObservationsByStep(actions) {
|
|
12683
|
+
const byStep = /* @__PURE__ */ new Map();
|
|
12684
|
+
for (const a of actions) {
|
|
12685
|
+
if (!a.stepId || !a.observation) continue;
|
|
12686
|
+
const list = byStep.get(a.stepId) ?? [];
|
|
12687
|
+
list.push(a.observation);
|
|
12688
|
+
byStep.set(a.stepId, list);
|
|
12689
|
+
}
|
|
12690
|
+
return byStep;
|
|
12691
|
+
}
|
|
12692
|
+
/** Per-step count of kept-but-replay-unstable actions (the flaky selectors). */
|
|
12693
|
+
function countUnstableByStep(actions) {
|
|
12694
|
+
const byStep = /* @__PURE__ */ new Map();
|
|
12695
|
+
for (const a of actions) {
|
|
12696
|
+
if (!a.stepId || !a.replayUnstable) continue;
|
|
12697
|
+
byStep.set(a.stepId, (byStep.get(a.stepId) ?? 0) + 1);
|
|
12698
|
+
}
|
|
12699
|
+
return byStep;
|
|
12700
|
+
}
|
|
12701
|
+
/**
|
|
12702
|
+
* One kept action as `action selector` (with the fill value or assert
|
|
12703
|
+
* type when present) — the canonical form the record playbook should reuse.
|
|
12704
|
+
* Unlike the live summary this keeps selectors verbatim: record learns
|
|
12705
|
+
* concrete per-spec selectors, so masking them would defeat the purpose.
|
|
12706
|
+
*
|
|
12707
|
+
* A replay-unstable action (kept in lenient mode but flagged because its
|
|
12708
|
+
* selector wasn't present on a fresh replay) is tagged `[unstable](<reason>)`
|
|
12709
|
+
* so the learner does NOT record its selector as canonical — the reason is the
|
|
12710
|
+
* teacher signal ("not present within Nms" = timing-fragile, not stable).
|
|
12711
|
+
*/
|
|
12712
|
+
function formatRecordedAction(a) {
|
|
12713
|
+
const parts = [a.action];
|
|
12714
|
+
if (a.index !== void 0) parts.push(String(a.index));
|
|
12715
|
+
const anchor = a.locator ? formatLocator(a.locator) : a.label;
|
|
12716
|
+
if (anchor) parts.push(anchor);
|
|
12717
|
+
if (a.value) parts.push(`= ${a.value}`);
|
|
12718
|
+
if (a.assert) parts.push(`(${a.assert})`);
|
|
12719
|
+
const line = oneLineSummary(parts.join(" "));
|
|
12720
|
+
return a.replayUnstable ? `${line} [unstable](${oneLineSummary(a.replayReason ?? "no reason")})` : line;
|
|
12721
|
+
}
|
|
12722
|
+
/** Verbatim locator form: raw selector for css, `by=value` for semantic ones. */
|
|
12723
|
+
function formatLocator(locator) {
|
|
12724
|
+
const name = locator.by === "role" && locator.name ? ` name=${locator.name}` : "";
|
|
12725
|
+
return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}${name}`;
|
|
12726
|
+
}
|
|
12727
|
+
function oneLineSummary(s) {
|
|
12728
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
12729
|
+
return flat.length > 240 ? flat.slice(0, 240) + "…" : flat || "(none)";
|
|
12730
|
+
}
|
|
12731
|
+
//#endregion
|
|
12732
|
+
//#region src/drift/format.ts
|
|
12733
|
+
/**
|
|
12734
|
+
* Render drift results as a string. The CLI commands and the `run` failure
|
|
12735
|
+
* hook are the only callers; both want the formatted output returned so
|
|
12736
|
+
* they can prefix / interleave / pipe it as needed.
|
|
12737
|
+
*/
|
|
12738
|
+
function renderDrift(results, format, cwd) {
|
|
12739
|
+
if (format === "json") return renderJson(results);
|
|
12740
|
+
if (format === "github") return renderGithub(results, cwd);
|
|
12741
|
+
return renderText(results);
|
|
12742
|
+
}
|
|
12743
|
+
const HEAVY_RULE = "═".repeat(72);
|
|
12744
|
+
function renderText(results) {
|
|
12745
|
+
const out = [];
|
|
12746
|
+
for (const r of results) {
|
|
12747
|
+
out.push("");
|
|
12748
|
+
const heading = `══ ${r.target.featureName}/${r.target.specName} `;
|
|
12749
|
+
const tail = "═".repeat(Math.max(3, 72 - heading.length));
|
|
12750
|
+
out.push(`${heading}${tail}`);
|
|
12751
|
+
if (r.error) {
|
|
12752
|
+
out.push(` ERROR ${r.error}`);
|
|
12753
|
+
continue;
|
|
12754
|
+
}
|
|
12755
|
+
const errors = r.issues.filter((i) => i.severity === "ERROR");
|
|
12756
|
+
const warnings = r.issues.filter((i) => i.severity === "WARN");
|
|
12757
|
+
const passed = r.issues.filter((i) => i.severity === "OK");
|
|
12758
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
12759
|
+
const label = passed.length === 1 ? "check" : "checks";
|
|
12760
|
+
const detail = passed.length > 0 ? `all ${passed.length} ${label} passed` : "no issues";
|
|
12761
|
+
out.push(` ✓ ${detail}`);
|
|
12762
|
+
continue;
|
|
12763
|
+
}
|
|
12764
|
+
for (const issue of errors) appendFinding(out, "ERROR", issue);
|
|
12765
|
+
for (const issue of warnings) appendFinding(out, "WARN", issue);
|
|
12766
|
+
if (passed.length > 0) {
|
|
12767
|
+
const names = passed.map((i) => DRAFT_CATEGORY_LABEL[i.category]).join(", ");
|
|
12768
|
+
out.push("");
|
|
12769
|
+
out.push(` ✓ passed (${passed.length}): ${names}`);
|
|
12770
|
+
}
|
|
12771
|
+
}
|
|
12772
|
+
out.push("");
|
|
12773
|
+
out.push(HEAVY_RULE);
|
|
12774
|
+
const totals = summarize(results);
|
|
12775
|
+
out.push(` specs ${results.length} (${totals.errored} errored)`);
|
|
12776
|
+
out.push(` findings ${totals.error} error, ${totals.warn} warn, ${totals.ok} ok`);
|
|
12777
|
+
out.push("");
|
|
12778
|
+
return out.join("\n");
|
|
12779
|
+
}
|
|
12780
|
+
function appendFinding(out, level, issue) {
|
|
12781
|
+
const stepPart = issue.stepId ? ` ${issue.stepId}` : "";
|
|
12782
|
+
out.push("");
|
|
12783
|
+
out.push(` ${level} ${DRAFT_CATEGORY_LABEL[issue.category]}${stepPart}`);
|
|
12784
|
+
out.push(` ${issue.message}`);
|
|
12785
|
+
if (issue.detail) out.push(` └ ${issue.detail.replace(/\n/g, "\n ")}`);
|
|
12786
|
+
}
|
|
12787
|
+
function renderJson(results) {
|
|
12788
|
+
const payload = { specs: results.map((r) => ({
|
|
12789
|
+
feature: r.target.featureName,
|
|
12790
|
+
spec: r.target.specName,
|
|
12791
|
+
ok: r.ok,
|
|
12792
|
+
...r.error ? { error: r.error } : {},
|
|
12793
|
+
issues: r.issues.map((i) => ({
|
|
12794
|
+
severity: i.severity,
|
|
12795
|
+
category: i.category,
|
|
12796
|
+
stepId: i.stepId,
|
|
12797
|
+
message: i.message,
|
|
12798
|
+
...i.detail ? { detail: i.detail } : {}
|
|
12799
|
+
}))
|
|
12800
|
+
})) };
|
|
12801
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
12802
|
+
}
|
|
12803
|
+
function renderGithub(results, cwd) {
|
|
12804
|
+
const repoRoot = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
|
|
12805
|
+
const lines = [];
|
|
12806
|
+
for (const r of results) {
|
|
12807
|
+
const file = githubRelPath(cwd, repoRoot, r.target.featureName, r.target.specName);
|
|
12808
|
+
if (r.error) {
|
|
12809
|
+
lines.push(`::error file=${file}::${escapeGhMessage(r.error)}`);
|
|
12810
|
+
continue;
|
|
12811
|
+
}
|
|
12812
|
+
for (const issue of r.issues) {
|
|
12813
|
+
if (issue.severity === "OK") continue;
|
|
12814
|
+
const level = issue.severity === "ERROR" ? "error" : "warning";
|
|
12815
|
+
const title = `${r.target.featureName}/${r.target.specName} — ${issue.category}${issue.stepId ? ` (${issue.stepId})` : ""}`;
|
|
12816
|
+
const body = issue.detail ? `${issue.message}\n${issue.detail}` : issue.message;
|
|
12817
|
+
lines.push(`::${level} file=${file},title=${escapeGhProp(title)}::${escapeGhMessage(body)}`);
|
|
12818
|
+
}
|
|
12819
|
+
}
|
|
12820
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
12806
12821
|
}
|
|
12807
|
-
|
|
12808
|
-
|
|
12809
|
-
|
|
12810
|
-
|
|
12822
|
+
function githubRelPath(cwd, repoRoot, featureName, specName) {
|
|
12823
|
+
const abs = resolve(cwd, ".ccqa", "features", featureName, "test-cases", specName, "spec.yaml");
|
|
12824
|
+
const rel = relative(repoRoot, abs);
|
|
12825
|
+
return rel.startsWith("..") ? abs : rel;
|
|
12811
12826
|
}
|
|
12812
|
-
function
|
|
12813
|
-
return
|
|
12814
|
-
|
|
12815
|
-
${specs.map((s) => `### ${s.featureName}/${s.specName}
|
|
12816
|
-
title: ${s.title}
|
|
12817
|
-
|
|
12818
|
-
\`\`\`yaml
|
|
12819
|
-
${s.specYaml.trimEnd()}
|
|
12820
|
-
\`\`\`
|
|
12821
|
-
`).join("\n")}
|
|
12822
|
-
${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
|
|
12823
|
-
|
|
12824
|
-
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.
|
|
12825
|
-
`;
|
|
12827
|
+
function escapeGhMessage(s) {
|
|
12828
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
12826
12829
|
}
|
|
12827
|
-
|
|
12828
|
-
|
|
12829
|
-
|
|
12830
|
-
|
|
12831
|
-
|
|
12832
|
-
|
|
12833
|
-
|
|
12834
|
-
|
|
12835
|
-
const
|
|
12836
|
-
|
|
12837
|
-
|
|
12838
|
-
|
|
12839
|
-
|
|
12840
|
-
}
|
|
12841
|
-
const existingRaw = await tryReadPerspectives() ?? "";
|
|
12842
|
-
const noteMap = extractNotes(existingRaw);
|
|
12843
|
-
const specBodies = await loadSpecBodies(skeleton);
|
|
12844
|
-
meta("language", opts.language ?? "auto");
|
|
12845
|
-
info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
|
|
12846
|
-
const summaries = await requestSummaries(specBodies, opts);
|
|
12847
|
-
if (summaries === null) process.exit(1);
|
|
12848
|
-
const merged = mergePerspectives(skeleton, summaries, noteMap);
|
|
12849
|
-
let validated;
|
|
12850
|
-
try {
|
|
12851
|
-
validated = PerspectivesSchema.parse(merged);
|
|
12852
|
-
} catch (e) {
|
|
12853
|
-
error(`refused to write: assembled perspectives failed validation (${e.message})`);
|
|
12854
|
-
process.exit(1);
|
|
12855
|
-
}
|
|
12856
|
-
const next = stringify(validated, { lineWidth: 0 });
|
|
12857
|
-
if (withoutGeneratedAt(existingRaw) === withoutGeneratedAt(next)) {
|
|
12858
|
-
blank();
|
|
12859
|
-
info("perspectives already up to date — no changes.");
|
|
12860
|
-
return;
|
|
12861
|
-
}
|
|
12862
|
-
blank();
|
|
12863
|
-
info("--- proposed changes (perspectives.yaml) ---");
|
|
12864
|
-
printUnifiedDiff(existingRaw, next);
|
|
12865
|
-
blank();
|
|
12866
|
-
if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "perspectives.yaml + .md を書き込みますか? [y/N] " : "Write perspectives.yaml + .md? [y/N] ")))) {
|
|
12867
|
-
info("aborted — no changes written.");
|
|
12868
|
-
return;
|
|
12830
|
+
function escapeGhProp(s) {
|
|
12831
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/,/g, "%2C").replace(/:/g, "%3A");
|
|
12832
|
+
}
|
|
12833
|
+
function summarize(results) {
|
|
12834
|
+
let error = 0;
|
|
12835
|
+
let warn = 0;
|
|
12836
|
+
let ok = 0;
|
|
12837
|
+
let errored = 0;
|
|
12838
|
+
for (const r of results) {
|
|
12839
|
+
if (r.error) errored++;
|
|
12840
|
+
for (const issue of r.issues) if (issue.severity === "ERROR") error++;
|
|
12841
|
+
else if (issue.severity === "WARN") warn++;
|
|
12842
|
+
else ok++;
|
|
12869
12843
|
}
|
|
12870
|
-
|
|
12871
|
-
|
|
12872
|
-
|
|
12873
|
-
|
|
12844
|
+
return {
|
|
12845
|
+
error,
|
|
12846
|
+
warn,
|
|
12847
|
+
ok,
|
|
12848
|
+
errored
|
|
12849
|
+
};
|
|
12874
12850
|
}
|
|
12851
|
+
//#endregion
|
|
12852
|
+
//#region src/drift/exit-code.ts
|
|
12875
12853
|
/**
|
|
12876
|
-
*
|
|
12877
|
-
*
|
|
12878
|
-
*
|
|
12879
|
-
* Specs whose spec.yaml is missing or unparsable are skipped.
|
|
12854
|
+
* Map drift results to an exit code. Spec-level errors (Claude call failed)
|
|
12855
|
+
* always fail; otherwise ERROR severity always fails, WARN fails only when
|
|
12856
|
+
* the threshold is `warn`.
|
|
12880
12857
|
*/
|
|
12881
|
-
|
|
12882
|
-
|
|
12883
|
-
|
|
12884
|
-
|
|
12885
|
-
|
|
12886
|
-
|
|
12887
|
-
|
|
12888
|
-
|
|
12889
|
-
|
|
12890
|
-
status
|
|
12891
|
-
};
|
|
12892
|
-
if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
|
|
12893
|
-
return entry;
|
|
12894
|
-
}));
|
|
12895
|
-
return {
|
|
12896
|
-
featureName: feature.featureName,
|
|
12897
|
-
specs
|
|
12898
|
-
};
|
|
12899
|
-
}))).filter((f) => f.specs.length > 0).map((f) => ({
|
|
12900
|
-
featureName: f.featureName,
|
|
12901
|
-
specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
|
|
12902
|
-
})).sort((a, b) => a.featureName.localeCompare(b.featureName));
|
|
12858
|
+
function determineExitCode(results, threshold) {
|
|
12859
|
+
for (const r of results) {
|
|
12860
|
+
if (r.error) return 1;
|
|
12861
|
+
for (const issue of r.issues) {
|
|
12862
|
+
if (issue.severity === "ERROR") return 1;
|
|
12863
|
+
if (threshold === "warn" && issue.severity === "WARN") return 1;
|
|
12864
|
+
}
|
|
12865
|
+
}
|
|
12866
|
+
return 0;
|
|
12903
12867
|
}
|
|
12904
12868
|
/**
|
|
12905
|
-
*
|
|
12906
|
-
*
|
|
12907
|
-
* else (title, status, summary) is recomputed. Returns an empty map when the
|
|
12908
|
-
* input is empty or unparsable — note preservation is best-effort and never
|
|
12909
|
-
* blocks regeneration.
|
|
12869
|
+
* Spec-level status under the given threshold, mirroring determineExitCode's
|
|
12870
|
+
* per-issue logic (exit-code.ts) but scoped to a single SpecResult.
|
|
12910
12871
|
*/
|
|
12911
|
-
function
|
|
12912
|
-
|
|
12913
|
-
|
|
12914
|
-
|
|
12915
|
-
|
|
12916
|
-
parsed = parse(existingRaw);
|
|
12917
|
-
} catch {
|
|
12918
|
-
return map;
|
|
12872
|
+
function specStatus(result, threshold) {
|
|
12873
|
+
if (result.error) return "failed";
|
|
12874
|
+
for (const issue of result.issues) {
|
|
12875
|
+
if (issue.severity === "ERROR") return "failed";
|
|
12876
|
+
if (threshold === "warn" && issue.severity === "WARN") return "failed";
|
|
12919
12877
|
}
|
|
12920
|
-
|
|
12921
|
-
if (!result.success) return map;
|
|
12922
|
-
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);
|
|
12923
|
-
return map;
|
|
12878
|
+
return "passed";
|
|
12924
12879
|
}
|
|
12925
12880
|
/**
|
|
12926
|
-
*
|
|
12927
|
-
*
|
|
12928
|
-
*
|
|
12881
|
+
* Adapts `ccqa drift` results into the shared RunReportData shape so they can
|
|
12882
|
+
* be pushed to the hub (`ccqa drift --push`) and rendered by the same report
|
|
12883
|
+
* UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
|
|
12884
|
+
* evidence, liveRun, ...) don't apply to a drift audit and are always null.
|
|
12929
12885
|
*/
|
|
12930
|
-
function
|
|
12931
|
-
const
|
|
12932
|
-
|
|
12933
|
-
|
|
12934
|
-
|
|
12935
|
-
|
|
12936
|
-
|
|
12937
|
-
|
|
12938
|
-
|
|
12939
|
-
|
|
12940
|
-
|
|
12941
|
-
|
|
12942
|
-
|
|
12943
|
-
|
|
12944
|
-
|
|
12945
|
-
|
|
12946
|
-
|
|
12947
|
-
return merged;
|
|
12948
|
-
})
|
|
12886
|
+
function driftResultsToReport(results, meta) {
|
|
12887
|
+
const specResults = results.map((result) => ({
|
|
12888
|
+
feature: result.target.featureName,
|
|
12889
|
+
spec: result.target.specName,
|
|
12890
|
+
title: null,
|
|
12891
|
+
status: specStatus(result, meta.threshold),
|
|
12892
|
+
testCounts: null,
|
|
12893
|
+
durationMs: null,
|
|
12894
|
+
assertions: null,
|
|
12895
|
+
analysis: null,
|
|
12896
|
+
analysisSkipped: null,
|
|
12897
|
+
driftIssues: result.issues,
|
|
12898
|
+
failureLogExcerpt: null,
|
|
12899
|
+
diffExcerpt: null,
|
|
12900
|
+
specYaml: null,
|
|
12901
|
+
evidence: null,
|
|
12902
|
+
liveRun: null
|
|
12949
12903
|
}));
|
|
12950
12904
|
return {
|
|
12951
|
-
|
|
12952
|
-
|
|
12905
|
+
schemaVersion: 1,
|
|
12906
|
+
kind: "drift",
|
|
12907
|
+
createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
12908
|
+
runId: meta.runId ?? null,
|
|
12909
|
+
git: meta.git,
|
|
12910
|
+
model: meta.model ?? null,
|
|
12911
|
+
language: meta.language ?? null,
|
|
12912
|
+
promptVersion: meta.promptVersion ?? "1",
|
|
12913
|
+
customPromptVersion: null,
|
|
12914
|
+
results: specResults
|
|
12953
12915
|
};
|
|
12954
12916
|
}
|
|
12917
|
+
//#endregion
|
|
12918
|
+
//#region src/drift/route-new-files.ts
|
|
12955
12919
|
/**
|
|
12956
|
-
*
|
|
12957
|
-
*
|
|
12958
|
-
*
|
|
12920
|
+
* Lightweight Claude call: given a list of new files in the PR and the existing
|
|
12921
|
+
* specs (with their relatedPaths globs as a hint), return the spec keys (in
|
|
12922
|
+
* "<feature>/<spec>" form) that the new files plausibly affect.
|
|
12923
|
+
*
|
|
12924
|
+
* Conservative by design — false positives are safer than false negatives,
|
|
12925
|
+
* because a missed spec turns into undetected drift in CI. When the router
|
|
12926
|
+
* call itself fails, we log a warning rather than fail-close: the surrounding
|
|
12927
|
+
* glob match is the primary signal; the router only adds coverage for new
|
|
12928
|
+
* paths no glob captures.
|
|
12959
12929
|
*/
|
|
12960
|
-
function
|
|
12961
|
-
|
|
12962
|
-
|
|
12963
|
-
|
|
12964
|
-
return `${featureName}/${specName}`;
|
|
12965
|
-
}
|
|
12966
|
-
async function readSpecMeta(featureName, specName) {
|
|
12967
|
-
const raw = await tryReadSpecFile(featureName, specName);
|
|
12968
|
-
if (raw === null) return {
|
|
12969
|
-
title: specName,
|
|
12970
|
-
mode: DEFAULT_SPEC_MODE
|
|
12971
|
-
};
|
|
12972
|
-
try {
|
|
12973
|
-
const parsed = parse(raw);
|
|
12974
|
-
const title = typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : specName;
|
|
12975
|
-
const modeResult = SpecModeSchema.safeParse(parsed.mode);
|
|
12976
|
-
return {
|
|
12977
|
-
title,
|
|
12978
|
-
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
|
|
12979
|
-
};
|
|
12980
|
-
} catch {
|
|
12981
|
-
return {
|
|
12982
|
-
title: specName,
|
|
12983
|
-
mode: DEFAULT_SPEC_MODE
|
|
12984
|
-
};
|
|
12985
|
-
}
|
|
12986
|
-
}
|
|
12987
|
-
async function deriveStatus(featureName, specName, mode) {
|
|
12988
|
-
return {
|
|
12989
|
-
mode,
|
|
12990
|
-
traced: await stat(join(getSpecDir(featureName, specName), "ir.json")).then(() => true).catch(() => false),
|
|
12991
|
-
generated: await getTestScript(featureName, specName) !== null
|
|
12992
|
-
};
|
|
12993
|
-
}
|
|
12994
|
-
async function loadSpecBodies(skeleton) {
|
|
12995
|
-
return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
|
|
12996
|
-
const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
|
|
12997
|
-
return {
|
|
12998
|
-
featureName: feature.featureName,
|
|
12999
|
-
specName: spec.specName,
|
|
13000
|
-
title: spec.title,
|
|
13001
|
-
specYaml
|
|
13002
|
-
};
|
|
13003
|
-
})));
|
|
13004
|
-
}
|
|
13005
|
-
async function requestSummaries(specs, opts) {
|
|
13006
|
-
const toolCounts = {};
|
|
13007
|
-
const startedAt = Date.now();
|
|
12930
|
+
async function routeNewFilesToSpecs(input) {
|
|
12931
|
+
const { newFiles, specs, cwd, model } = input;
|
|
12932
|
+
const empty = /* @__PURE__ */ new Set();
|
|
12933
|
+
if (newFiles.length === 0 || specs.length === 0) return empty;
|
|
13008
12934
|
const { result, isError } = await invokeClaudeStreaming({
|
|
13009
|
-
prompt:
|
|
13010
|
-
|
|
12935
|
+
prompt: buildRouterPrompt(await Promise.all(newFiles.map(async (path) => ({
|
|
12936
|
+
path,
|
|
12937
|
+
head: await readHead(join(cwd, path))
|
|
12938
|
+
}))), specs),
|
|
12939
|
+
systemPrompt: buildRouterSystemPrompt(),
|
|
13011
12940
|
allowedTools: [
|
|
13012
12941
|
"Read",
|
|
13013
12942
|
"Grep",
|
|
13014
12943
|
"Glob"
|
|
13015
12944
|
],
|
|
13016
12945
|
silenceBashLog: true,
|
|
13017
|
-
|
|
13018
|
-
|
|
13019
|
-
|
|
13020
|
-
for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
|
|
13021
|
-
});
|
|
13022
|
-
process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
|
|
12946
|
+
cwd,
|
|
12947
|
+
...model ? { model } : {}
|
|
12948
|
+
}, (_msg) => {});
|
|
13023
12949
|
if (isError) {
|
|
13024
|
-
|
|
13025
|
-
return
|
|
12950
|
+
warn("new-file router: Claude returned an error; skipping router signal");
|
|
12951
|
+
return empty;
|
|
13026
12952
|
}
|
|
13027
12953
|
const json = extractJsonBlock(result);
|
|
13028
12954
|
if (!json) {
|
|
13029
|
-
|
|
13030
|
-
return
|
|
12955
|
+
warn("new-file router: no JSON block in response; skipping router signal");
|
|
12956
|
+
return empty;
|
|
13031
12957
|
}
|
|
13032
|
-
|
|
12958
|
+
let parsed;
|
|
12959
|
+
try {
|
|
12960
|
+
parsed = JSON.parse(json);
|
|
12961
|
+
} catch (e) {
|
|
12962
|
+
warn(`new-file router: failed to parse JSON (${e.message}); skipping router signal`);
|
|
12963
|
+
return empty;
|
|
12964
|
+
}
|
|
12965
|
+
const out = /* @__PURE__ */ new Set();
|
|
12966
|
+
const validKeys = new Set(specs.map((s) => `${s.featureName}/${s.specName}`));
|
|
12967
|
+
if (typeof parsed === "object" && parsed !== null && "affectedSpecs" in parsed) {
|
|
12968
|
+
const arr = parsed.affectedSpecs;
|
|
12969
|
+
if (Array.isArray(arr)) {
|
|
12970
|
+
for (const item of arr) if (typeof item === "string" && validKeys.has(item)) out.add(item);
|
|
12971
|
+
}
|
|
12972
|
+
}
|
|
12973
|
+
return out;
|
|
12974
|
+
}
|
|
12975
|
+
async function readHead(absPath) {
|
|
12976
|
+
const content = await readFile(absPath, "utf-8").catch(() => "");
|
|
12977
|
+
if (!content) return "";
|
|
12978
|
+
return content.split("\n").slice(0, 40).join("\n");
|
|
12979
|
+
}
|
|
12980
|
+
function buildRouterSystemPrompt() {
|
|
12981
|
+
return `You triage which ccqa test specs are potentially affected by NEW source files added in a pull request.
|
|
12982
|
+
|
|
12983
|
+
You will receive:
|
|
12984
|
+
- A list of new files (path + first ~40 lines of each)
|
|
12985
|
+
- A list of existing specs with their declared relatedPaths globs
|
|
12986
|
+
|
|
12987
|
+
Your job: return the spec keys (in "<feature>/<spec>" form) whose behaviour might depend on any of the new files.
|
|
12988
|
+
|
|
12989
|
+
## Rules
|
|
12990
|
+
|
|
12991
|
+
- Be **conservative**: when in doubt, include the spec. A spurious inclusion costs one extra drift check; a missed spec lets real drift slip through CI.
|
|
12992
|
+
- Use \`Read\`, \`Grep\`, \`Glob\` if you need to inspect the spec body or related code, but stay focused — this is a triage step, not a full review.
|
|
12993
|
+
- Ignore specs whose relatedPaths clearly point to a different area than every new file (e.g. \`src/auth/**\` specs vs new files only under \`src/billing/**\`).
|
|
12994
|
+
- Files like tests, generated code, build artifacts, vendor dirs typically do not affect any spec. Skip them.
|
|
12995
|
+
|
|
12996
|
+
## Output (STRICT)
|
|
12997
|
+
|
|
12998
|
+
Output ONE fenced \`\`\`json block, nothing else:
|
|
12999
|
+
|
|
13000
|
+
\`\`\`json
|
|
13001
|
+
{
|
|
13002
|
+
"affectedSpecs": ["feature/spec", "feature/spec"]
|
|
13003
|
+
}
|
|
13004
|
+
\`\`\`
|
|
13005
|
+
|
|
13006
|
+
Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
|
|
13007
|
+
`;
|
|
13008
|
+
}
|
|
13009
|
+
function buildRouterPrompt(previews, specs) {
|
|
13010
|
+
return `## New files
|
|
13011
|
+
|
|
13012
|
+
${previews.map((p) => {
|
|
13013
|
+
const headBlock = p.head ? `\n\`\`\`\n${p.head}\n\`\`\`` : "\n(empty or unreadable)";
|
|
13014
|
+
return `### ${p.path}${headBlock}`;
|
|
13015
|
+
}).join("\n\n")}
|
|
13016
|
+
|
|
13017
|
+
## Existing specs
|
|
13018
|
+
|
|
13019
|
+
${specs.map((s) => {
|
|
13020
|
+
const paths = s.relatedPaths.length === 0 ? " (no relatedPaths declared)" : s.relatedPaths.map((p) => ` - ${p}`).join("\n");
|
|
13021
|
+
return `- ${s.featureName}/${s.specName}\n${paths}`;
|
|
13022
|
+
}).join("\n")}
|
|
13023
|
+
|
|
13024
|
+
## Task
|
|
13025
|
+
|
|
13026
|
+
Return the spec keys that might be affected by any of the new files. Conservative inclusion is preferred over missing real drift.
|
|
13027
|
+
`;
|
|
13033
13028
|
}
|
|
13029
|
+
//#endregion
|
|
13030
|
+
//#region src/cli/drift.ts
|
|
13031
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
13032
|
+
const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").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.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(async (specPath, opts) => {
|
|
13033
|
+
const format = parseFormat(opts.format);
|
|
13034
|
+
const threshold = parseSeverity(opts.severity);
|
|
13035
|
+
const concurrency = parseConcurrency(opts.concurrency);
|
|
13036
|
+
const cwd = resolveCwd(opts.cwd);
|
|
13037
|
+
await ensureCcqaDir(cwd);
|
|
13038
|
+
if (opts.changed && specPath) {
|
|
13039
|
+
error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
|
|
13040
|
+
process.exit(2);
|
|
13041
|
+
}
|
|
13042
|
+
let targets = await collectTargets(specPath, cwd);
|
|
13043
|
+
if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
|
|
13044
|
+
if (format === "text") {
|
|
13045
|
+
header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
|
|
13046
|
+
if (opts.cwd) meta("cwd", cwd);
|
|
13047
|
+
}
|
|
13048
|
+
const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
|
|
13049
|
+
if (opts.changed) {
|
|
13050
|
+
const total = targets.length;
|
|
13051
|
+
targets = await filterByChanged({
|
|
13052
|
+
targets,
|
|
13053
|
+
cwd,
|
|
13054
|
+
baseOverride: opts.base,
|
|
13055
|
+
format,
|
|
13056
|
+
model: opts.model
|
|
13057
|
+
});
|
|
13058
|
+
if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
|
|
13059
|
+
if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
|
|
13060
|
+
}
|
|
13061
|
+
const blocks = await loadAvailableBlocks(cwd);
|
|
13062
|
+
const results = await analyzeDrift({
|
|
13063
|
+
targets,
|
|
13064
|
+
cwd,
|
|
13065
|
+
blocks,
|
|
13066
|
+
concurrency,
|
|
13067
|
+
...opts.model ? { model: opts.model } : {},
|
|
13068
|
+
...opts.language ? { language: opts.language } : {},
|
|
13069
|
+
onSpecStart: (t) => {
|
|
13070
|
+
if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
|
|
13071
|
+
}
|
|
13072
|
+
});
|
|
13073
|
+
process.stdout.write(renderDrift(results, format, cwd));
|
|
13074
|
+
if (opts.push) await pushDriftResults({
|
|
13075
|
+
results,
|
|
13076
|
+
threshold,
|
|
13077
|
+
cwd,
|
|
13078
|
+
opts,
|
|
13079
|
+
format,
|
|
13080
|
+
baseRef
|
|
13081
|
+
});
|
|
13082
|
+
process.exit(determineExitCode(results, threshold));
|
|
13083
|
+
});
|
|
13034
13084
|
/**
|
|
13035
|
-
*
|
|
13036
|
-
*
|
|
13085
|
+
* Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
|
|
13086
|
+
* shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
|
|
13087
|
+
* hub connection warns and returns rather than failing the command (`--push`
|
|
13088
|
+
* never changes drift's own exit code).
|
|
13089
|
+
*
|
|
13090
|
+
* `resolveHub` is injectable so tests can supply a fake `HubClient` without
|
|
13091
|
+
* a real hub connection; it defaults to the real flag/env resolution.
|
|
13037
13092
|
*/
|
|
13038
|
-
function
|
|
13039
|
-
|
|
13093
|
+
async function pushDriftResults(args, resolveHub = resolveHubClient) {
|
|
13094
|
+
const { results, threshold, cwd, opts, format, baseRef } = args;
|
|
13095
|
+
const hub = resolveHub(opts);
|
|
13096
|
+
if (!hub) {
|
|
13097
|
+
warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
|
|
13098
|
+
return;
|
|
13099
|
+
}
|
|
13040
13100
|
try {
|
|
13041
|
-
|
|
13101
|
+
const project = resolveProject({
|
|
13102
|
+
project: opts.project,
|
|
13103
|
+
cwd
|
|
13104
|
+
});
|
|
13105
|
+
const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
13106
|
+
const report = driftResultsToReport(results, {
|
|
13107
|
+
threshold,
|
|
13108
|
+
git: {
|
|
13109
|
+
head,
|
|
13110
|
+
base: baseRef ?? null
|
|
13111
|
+
}
|
|
13112
|
+
});
|
|
13113
|
+
const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
|
|
13114
|
+
try {
|
|
13115
|
+
await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
|
|
13116
|
+
const archive = await packDirToTarGz(dir);
|
|
13117
|
+
const run = await hub.pushRun(archive, {
|
|
13118
|
+
project,
|
|
13119
|
+
...branch ? { branch } : {},
|
|
13120
|
+
kind: "drift"
|
|
13121
|
+
});
|
|
13122
|
+
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
|
|
13123
|
+
} finally {
|
|
13124
|
+
await rm(dir, {
|
|
13125
|
+
recursive: true,
|
|
13126
|
+
force: true
|
|
13127
|
+
});
|
|
13128
|
+
}
|
|
13129
|
+
} catch (err) {
|
|
13130
|
+
if (err instanceof HubApiError) {
|
|
13131
|
+
error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
|
|
13132
|
+
process.exit(2);
|
|
13133
|
+
}
|
|
13134
|
+
throw err;
|
|
13135
|
+
}
|
|
13136
|
+
}
|
|
13137
|
+
function exitWithNoSpecs(format, message) {
|
|
13138
|
+
if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
|
|
13139
|
+
else if (format === "text") info(message);
|
|
13140
|
+
process.exit(0);
|
|
13141
|
+
}
|
|
13142
|
+
async function filterByChanged(input) {
|
|
13143
|
+
const { targets, cwd, baseOverride, format, model } = input;
|
|
13144
|
+
const base = resolveBaseRef(baseOverride);
|
|
13145
|
+
let changed;
|
|
13146
|
+
try {
|
|
13147
|
+
changed = await getChangedFiles(base, cwd);
|
|
13042
13148
|
} catch (e) {
|
|
13043
|
-
error(`failed to
|
|
13044
|
-
|
|
13149
|
+
error(`failed to run 'git diff' against ${base}: ${e.message}`);
|
|
13150
|
+
process.exit(2);
|
|
13045
13151
|
}
|
|
13046
|
-
if (
|
|
13047
|
-
|
|
13048
|
-
|
|
13152
|
+
if (format === "text") {
|
|
13153
|
+
meta("changed-base", base);
|
|
13154
|
+
meta("changed-files", changed.length);
|
|
13155
|
+
}
|
|
13156
|
+
if (changed.length === 0) return [];
|
|
13157
|
+
const newFiles = changed.filter((f) => f.status === "added" && !f.outsideCwd);
|
|
13158
|
+
const existingChanges = changed.filter((f) => f.status !== "added" || f.outsideCwd);
|
|
13159
|
+
const affected = /* @__PURE__ */ new Set();
|
|
13160
|
+
const touchedBlockNames = /* @__PURE__ */ new Set();
|
|
13161
|
+
for (const f of changed) {
|
|
13162
|
+
if (f.outsideCwd) continue;
|
|
13163
|
+
const blockName = parseBlockPath(f.path);
|
|
13164
|
+
if (blockName) touchedBlockNames.add(blockName);
|
|
13165
|
+
}
|
|
13166
|
+
for (const t of targets) {
|
|
13167
|
+
if (!t.relatedPaths) {
|
|
13168
|
+
affected.add(specKey(t));
|
|
13169
|
+
continue;
|
|
13170
|
+
}
|
|
13171
|
+
if (existingChanges.some((f) => isPathAffectedBy(f.path, t.relatedPaths)) || newFiles.some((f) => isPathAffectedBy(f.path, t.relatedPaths))) {
|
|
13172
|
+
affected.add(specKey(t));
|
|
13173
|
+
continue;
|
|
13174
|
+
}
|
|
13175
|
+
if (t.includedBlocks?.some((name) => touchedBlockNames.has(name))) affected.add(specKey(t));
|
|
13176
|
+
}
|
|
13177
|
+
if (newFiles.length > 0) {
|
|
13178
|
+
if (format === "text") info(`routing ${newFiles.length} new file(s) to specs via Claude...`);
|
|
13179
|
+
const routed = await routeNewFilesToSpecs({
|
|
13180
|
+
newFiles: newFiles.map((f) => f.path),
|
|
13181
|
+
specs: targets.filter((t) => t.relatedPaths).map((t) => ({
|
|
13182
|
+
featureName: t.featureName,
|
|
13183
|
+
specName: t.specName,
|
|
13184
|
+
relatedPaths: t.relatedPaths
|
|
13185
|
+
})),
|
|
13186
|
+
cwd,
|
|
13187
|
+
model
|
|
13188
|
+
});
|
|
13189
|
+
for (const key of routed) affected.add(key);
|
|
13049
13190
|
}
|
|
13050
|
-
|
|
13051
|
-
|
|
13052
|
-
|
|
13053
|
-
|
|
13191
|
+
return targets.filter((t) => affected.has(specKey(t)));
|
|
13192
|
+
}
|
|
13193
|
+
async function collectTargets(specPath, cwd) {
|
|
13194
|
+
const tree = await listFeatureTree(cwd);
|
|
13195
|
+
if (specPath) {
|
|
13196
|
+
const { featureName, specName } = parseSpecPath(specPath);
|
|
13197
|
+
const spec = tree.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
|
|
13198
|
+
if (!spec?.hasSpecFile) {
|
|
13199
|
+
error(`spec not found: ${featureName}/${specName} (under ${cwd})`);
|
|
13200
|
+
process.exit(1);
|
|
13201
|
+
}
|
|
13202
|
+
return [{
|
|
13203
|
+
featureName,
|
|
13204
|
+
specName,
|
|
13205
|
+
includedBlocks: spec.includedBlocks ?? []
|
|
13206
|
+
}];
|
|
13054
13207
|
}
|
|
13055
13208
|
const out = [];
|
|
13056
|
-
for (const
|
|
13057
|
-
|
|
13058
|
-
const
|
|
13059
|
-
|
|
13060
|
-
|
|
13061
|
-
|
|
13062
|
-
|
|
13063
|
-
|
|
13064
|
-
|
|
13065
|
-
if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
|
|
13066
|
-
if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
|
|
13067
|
-
if (Array.isArray(rec.preconditions)) {
|
|
13068
|
-
const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
|
|
13069
|
-
if (pre.length > 0) entry.preconditions = pre;
|
|
13070
|
-
}
|
|
13071
|
-
out.push(entry);
|
|
13072
|
-
}
|
|
13209
|
+
for (const feature of tree) for (const spec of feature.specs) {
|
|
13210
|
+
if (!spec.hasSpecFile) continue;
|
|
13211
|
+
const t = {
|
|
13212
|
+
featureName: feature.featureName,
|
|
13213
|
+
specName: spec.specName
|
|
13214
|
+
};
|
|
13215
|
+
if (spec.relatedPaths) t.relatedPaths = spec.relatedPaths;
|
|
13216
|
+
if (spec.includedBlocks) t.includedBlocks = spec.includedBlocks;
|
|
13217
|
+
out.push(t);
|
|
13073
13218
|
}
|
|
13074
13219
|
return out;
|
|
13075
13220
|
}
|
|
13076
|
-
|
|
13077
|
-
|
|
13078
|
-
|
|
13079
|
-
|
|
13080
|
-
|
|
13081
|
-
modeCol: "モード",
|
|
13082
|
-
statusCol: "状態",
|
|
13083
|
-
modeLabel: "モード",
|
|
13084
|
-
summary: "検証内容",
|
|
13085
|
-
preconditions: "前提条件",
|
|
13086
|
-
startScreen: "開始画面",
|
|
13087
|
-
relatedCode: "関連コード",
|
|
13088
|
-
modeDeterministic: "deterministic",
|
|
13089
|
-
modeLive: "live",
|
|
13090
|
-
notRunnable: "⚠️ 未record",
|
|
13091
|
-
runnable: "✅ 実行可能"
|
|
13092
|
-
};
|
|
13093
|
-
const LABELS_EN = {
|
|
13094
|
-
indexTitle: "Test Perspectives (perspectives)",
|
|
13095
|
-
caseCol: "Case",
|
|
13096
|
-
itemCol: "Item",
|
|
13097
|
-
valueCol: "Value",
|
|
13098
|
-
modeCol: "Mode",
|
|
13099
|
-
statusCol: "Status",
|
|
13100
|
-
modeLabel: "Mode",
|
|
13101
|
-
summary: "Verifies",
|
|
13102
|
-
preconditions: "Preconditions",
|
|
13103
|
-
startScreen: "Start screen",
|
|
13104
|
-
relatedCode: "Related code",
|
|
13105
|
-
modeDeterministic: "deterministic",
|
|
13106
|
-
modeLive: "live",
|
|
13107
|
-
notRunnable: "⚠️ not recorded",
|
|
13108
|
-
runnable: "✅ runnable"
|
|
13109
|
-
};
|
|
13110
|
-
/**
|
|
13111
|
-
* Pick the label set for a `--language` value. Only an explicit English tag
|
|
13112
|
-
* (`en`, `en-US`, …) switches to English labels; `auto`, `ja`, and anything
|
|
13113
|
-
* else keep Japanese, matching the source-following default the rest of the
|
|
13114
|
-
* command uses.
|
|
13115
|
-
*/
|
|
13116
|
-
function labelsFor(language) {
|
|
13117
|
-
return /^en\b/i.test(language?.trim() ?? "") ? LABELS_EN : LABELS_JA;
|
|
13118
|
-
}
|
|
13119
|
-
/**
|
|
13120
|
-
* Whether the spec is runnable from the reviewer's perspective. Live specs
|
|
13121
|
-
* are always runnable (no codegen step); a deterministic spec is runnable
|
|
13122
|
-
* only once `test.spec.ts` exists.
|
|
13123
|
-
*/
|
|
13124
|
-
function statusLabel(status, labels) {
|
|
13125
|
-
if (status.mode === "live") return labels.runnable;
|
|
13126
|
-
return status.generated ? labels.runnable : labels.notRunnable;
|
|
13127
|
-
}
|
|
13128
|
-
/** The spec's execution mode (deterministic or live), per spec.yaml. */
|
|
13129
|
-
function modeLabel(status, labels) {
|
|
13130
|
-
return status.mode === "live" ? labels.modeLive : labels.modeDeterministic;
|
|
13131
|
-
}
|
|
13132
|
-
/**
|
|
13133
|
-
* Path to a spec.yaml relative to the **root** `.ccqa/perspectives.md`
|
|
13134
|
-
* (i.e. relative to the `.ccqa/` dir). Used for the category index links.
|
|
13135
|
-
*/
|
|
13136
|
-
function specRelPathFromRoot(featureName, specName) {
|
|
13137
|
-
return `features/${featureName}/test-cases/${specName}/spec.yaml`;
|
|
13138
|
-
}
|
|
13139
|
-
/**
|
|
13140
|
-
* Path to a category detail file relative to the **root** `.ccqa/perspectives.md`.
|
|
13141
|
-
* The detail file is written to `.ccqa/features/<feature>/perspectives.md`
|
|
13142
|
-
* (see `getFeaturePerspectivesMarkdownPath`), so the link must include the
|
|
13143
|
-
* `features/` segment — otherwise the category heading link 404s.
|
|
13144
|
-
*/
|
|
13145
|
-
function featureDetailRelPathFromRoot(featureName) {
|
|
13146
|
-
return `features/${featureName}/perspectives.md`;
|
|
13221
|
+
function parseFormat(raw) {
|
|
13222
|
+
const v = raw ?? "text";
|
|
13223
|
+
if (v === "text" || v === "json" || v === "github") return v;
|
|
13224
|
+
error(`invalid --format: ${v} (expected text|json|github)`);
|
|
13225
|
+
process.exit(2);
|
|
13147
13226
|
}
|
|
13148
|
-
|
|
13149
|
-
|
|
13150
|
-
|
|
13151
|
-
|
|
13152
|
-
|
|
13153
|
-
*/
|
|
13154
|
-
function specRelPathFromCategory(specName) {
|
|
13155
|
-
return `test-cases/${specName}/spec.yaml`;
|
|
13227
|
+
function parseSeverity(raw) {
|
|
13228
|
+
const v = raw ?? "error";
|
|
13229
|
+
if (v === "warn" || v === "error") return v;
|
|
13230
|
+
error(`invalid --severity: ${v} (expected warn|error)`);
|
|
13231
|
+
process.exit(2);
|
|
13156
13232
|
}
|
|
13157
|
-
|
|
13158
|
-
|
|
13159
|
-
|
|
13160
|
-
|
|
13161
|
-
|
|
13162
|
-
|
|
13163
|
-
* "what is tested, and where" overview.
|
|
13164
|
-
*
|
|
13165
|
-
* Pure and deterministic, so the index rendering is easy to unit-test.
|
|
13166
|
-
*/
|
|
13167
|
-
function renderIndexMarkdown(perspectives, labels = LABELS_JA) {
|
|
13168
|
-
const lines = [];
|
|
13169
|
-
lines.push(`# ${labels.indexTitle}`);
|
|
13170
|
-
lines.push("");
|
|
13171
|
-
for (const feature of perspectives.features) {
|
|
13172
|
-
const detailLink = featureDetailRelPathFromRoot(feature.featureName);
|
|
13173
|
-
lines.push(`## [${feature.featureName}](${detailLink})`);
|
|
13174
|
-
lines.push("");
|
|
13175
|
-
lines.push(`| ${labels.caseCol} | ${labels.modeCol} | ${labels.statusCol} | spec |`);
|
|
13176
|
-
lines.push("| --- | --- | --- | --- |");
|
|
13177
|
-
for (const spec of feature.specs) {
|
|
13178
|
-
const specLink = specRelPathFromRoot(feature.featureName, spec.specName);
|
|
13179
|
-
const mode = mdCell(modeLabel(spec.status, labels));
|
|
13180
|
-
const status = mdCell(statusLabel(spec.status, labels));
|
|
13181
|
-
lines.push(`| ${mdCell(spec.title)} | ${mode} | ${status} | [spec](${specLink}) |`);
|
|
13182
|
-
}
|
|
13183
|
-
lines.push("");
|
|
13233
|
+
function parseConcurrency(raw) {
|
|
13234
|
+
if (raw === void 0) return DEFAULT_CONCURRENCY;
|
|
13235
|
+
const n = Number.parseInt(raw, 10);
|
|
13236
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
13237
|
+
error(`invalid --concurrency: ${raw} (expected positive integer)`);
|
|
13238
|
+
process.exit(2);
|
|
13184
13239
|
}
|
|
13185
|
-
return
|
|
13186
|
-
}
|
|
13187
|
-
/**
|
|
13188
|
-
* Render one category's `.ccqa/features/<feature>/perspectives.md`: every
|
|
13189
|
-
* case in the category as a self-contained vertical table. All columns —
|
|
13190
|
-
* including the verification summary (検証内容) and the human note — live
|
|
13191
|
-
* inside the table; nothing is emitted outside it. Detailed steps / expected
|
|
13192
|
-
* results are still not restated (the spec.yaml is their single home); the
|
|
13193
|
-
* table links back to each spec instead.
|
|
13194
|
-
*
|
|
13195
|
-
* Pure and deterministic, so the per-case rendering is easy to unit-test.
|
|
13196
|
-
*/
|
|
13197
|
-
function renderFeatureMarkdown(feature, labels = LABELS_JA) {
|
|
13198
|
-
const lines = [];
|
|
13199
|
-
lines.push(`# ${feature.featureName}`);
|
|
13200
|
-
lines.push("");
|
|
13201
|
-
for (const spec of feature.specs) lines.push(...renderSpecMarkdown(spec, labels));
|
|
13202
|
-
return lines.join("\n");
|
|
13203
|
-
}
|
|
13204
|
-
/**
|
|
13205
|
-
* Render one spec as a single vertical (item | content) Markdown table for a
|
|
13206
|
-
* category file. Verification summary and preconditions lead. The spec link
|
|
13207
|
-
* is relative to this category file so it resolves both on GitHub and in a
|
|
13208
|
-
* local editor. Related-code paths stay inline code rather than links: their
|
|
13209
|
-
* base (the cwd that hosts `.ccqa/`) is not reliably recoverable here — specs
|
|
13210
|
-
* carry a mix of cwd-relative (`src/...`) and repo-root (`pkg/app/src/...`)
|
|
13211
|
-
* forms — and many are globs that no link could open anyway. 検証内容
|
|
13212
|
-
* (summary) and note are rows inside the table; no prose blocks are emitted
|
|
13213
|
-
* around it. Exported for focused unit testing.
|
|
13214
|
-
*/
|
|
13215
|
-
function renderSpecMarkdown(spec, labels = LABELS_JA) {
|
|
13216
|
-
const lines = [];
|
|
13217
|
-
lines.push(`## ${spec.title}`);
|
|
13218
|
-
lines.push("");
|
|
13219
|
-
lines.push(`| ${labels.itemCol} | ${labels.valueCol} |`);
|
|
13220
|
-
lines.push("| --- | --- |");
|
|
13221
|
-
if (spec.summary) lines.push(`| ${labels.summary} | ${mdCell(spec.summary)} |`);
|
|
13222
|
-
if (spec.preconditions && spec.preconditions.length > 0) lines.push(`| ${labels.preconditions} | ${spec.preconditions.map(mdCell).join("<br>")} |`);
|
|
13223
|
-
if (spec.startScreen) lines.push(`| ${labels.startScreen} | ${mdCell(spec.startScreen)} |`);
|
|
13224
|
-
const specPath = specRelPathFromCategory(spec.specName);
|
|
13225
|
-
lines.push(`| spec | [${specPath}](${specPath}) |`);
|
|
13226
|
-
lines.push(`| ${labels.modeLabel} | ${mdCell(modeLabel(spec.status, labels))} |`);
|
|
13227
|
-
lines.push(`| ${labels.statusCol} | ${mdCell(statusLabel(spec.status, labels))} |`);
|
|
13228
|
-
if (spec.relatedPaths && spec.relatedPaths.length > 0) lines.push(`| ${labels.relatedCode} | ${spec.relatedPaths.map((p) => `\`${p}\``).join("<br>")} |`);
|
|
13229
|
-
if (spec.note) lines.push(`| 📝 note | ${mdCell(spec.note)} |`);
|
|
13230
|
-
lines.push("");
|
|
13231
|
-
return lines;
|
|
13232
|
-
}
|
|
13233
|
-
/** Escape pipes / newlines so a value stays inside one Markdown table cell. */
|
|
13234
|
-
function mdCell(value) {
|
|
13235
|
-
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
13240
|
+
return n;
|
|
13236
13241
|
}
|
|
13237
13242
|
//#endregion
|
|
13243
|
+
//#region src/cli/init.ts
|
|
13244
|
+
const initCommand = new Command("init").description("Create the .ccqa/ spec skeleton (features/, blocks/) in the current directory.").option("--cwd <path>", "Working directory (default: cwd)").action(async (opts) => {
|
|
13245
|
+
const cwd = resolveCwd(opts.cwd);
|
|
13246
|
+
header("init", cwd);
|
|
13247
|
+
await ensureCcqaDir(cwd);
|
|
13248
|
+
info("created .ccqa/features/, .ccqa/blocks/");
|
|
13249
|
+
blank();
|
|
13250
|
+
hint("set CCQA_HUB_URL / CCQA_HUB_TOKEN to connect to a ccqa hub");
|
|
13251
|
+
hint("edit guidance prompts in the hub UI's Prompts tab");
|
|
13252
|
+
hint("run `ccqa record <feature>/<spec>` to start recording");
|
|
13253
|
+
});
|
|
13254
|
+
//#endregion
|
|
13238
13255
|
//#region src/cli/session.ts
|
|
13239
13256
|
const AB = createRequire(import.meta.url).resolve("agent-browser/bin/agent-browser.js");
|
|
13240
13257
|
/**
|
|
@@ -14107,7 +14124,7 @@ function createListProfilesHandler(storage) {
|
|
|
14107
14124
|
//#region src/hub/api/handlers/prompts.ts
|
|
14108
14125
|
const MAX_PROMPT_BODY_BYTES = 256 * 1024;
|
|
14109
14126
|
/** Validate the `:project` route param (prompts are project-scoped, not per-profile). */
|
|
14110
|
-
function requireProject(ctx) {
|
|
14127
|
+
function requireProject$1(ctx) {
|
|
14111
14128
|
return requireSafeSegment(ctx.params.project, "project");
|
|
14112
14129
|
}
|
|
14113
14130
|
/**
|
|
@@ -14143,7 +14160,7 @@ function metaFor(name, body) {
|
|
|
14143
14160
|
/** PUT /api/v1/projects/:project/prompts/:name — body is Markdown (guidance) or custom prompt JSON. */
|
|
14144
14161
|
function createPutPromptHandler(config) {
|
|
14145
14162
|
return async (ctx) => {
|
|
14146
|
-
const project = requireProject(ctx);
|
|
14163
|
+
const project = requireProject$1(ctx);
|
|
14147
14164
|
const name = requirePromptName(ctx);
|
|
14148
14165
|
const body = await readBody(ctx.req, MAX_PROMPT_BODY_BYTES);
|
|
14149
14166
|
await config.store.put(project, name, new Uint8Array(body), metaFor(name, body));
|
|
@@ -14154,7 +14171,7 @@ function createPutPromptHandler(config) {
|
|
|
14154
14171
|
/** GET /api/v1/projects/:project/prompts — names + kinds + timestamps (no bodies). */
|
|
14155
14172
|
function createListPromptsHandler(config) {
|
|
14156
14173
|
return async (ctx) => {
|
|
14157
|
-
const project = requireProject(ctx);
|
|
14174
|
+
const project = requireProject$1(ctx);
|
|
14158
14175
|
const entries = await config.store.list(project);
|
|
14159
14176
|
sendJson(ctx.res, 200, { prompts: entries.map((e) => ({
|
|
14160
14177
|
name: e.name,
|
|
@@ -14167,7 +14184,7 @@ function createListPromptsHandler(config) {
|
|
|
14167
14184
|
/** GET /api/v1/projects/:project/prompts/:name — the raw prompt body, or 404. */
|
|
14168
14185
|
function createGetPromptHandler(config) {
|
|
14169
14186
|
return async (ctx) => {
|
|
14170
|
-
const project = requireProject(ctx);
|
|
14187
|
+
const project = requireProject$1(ctx);
|
|
14171
14188
|
const name = requirePromptName(ctx);
|
|
14172
14189
|
const stored = await config.store.get(project, name);
|
|
14173
14190
|
if (!stored) throw new HttpError(404, "not_found", `prompt "${name}" not found for project "${project}"`);
|
|
@@ -14178,7 +14195,7 @@ function createGetPromptHandler(config) {
|
|
|
14178
14195
|
/** DELETE /api/v1/projects/:project/prompts/:name */
|
|
14179
14196
|
function createDeletePromptHandler(config) {
|
|
14180
14197
|
return async (ctx) => {
|
|
14181
|
-
const project = requireProject(ctx);
|
|
14198
|
+
const project = requireProject$1(ctx);
|
|
14182
14199
|
const name = requirePromptName(ctx);
|
|
14183
14200
|
await config.store.delete(project, name);
|
|
14184
14201
|
ctx.res.statusCode = 204;
|
|
@@ -14186,6 +14203,93 @@ function createDeletePromptHandler(config) {
|
|
|
14186
14203
|
};
|
|
14187
14204
|
}
|
|
14188
14205
|
//#endregion
|
|
14206
|
+
//#region src/hub/api/handlers/perspectives.ts
|
|
14207
|
+
const MAX_PERSPECTIVES_BODY_BYTES = 1024 * 1024;
|
|
14208
|
+
/** Validate the `:project` route param (perspectives are project-scoped, one document per project). */
|
|
14209
|
+
function requireProject(ctx) {
|
|
14210
|
+
return requireSafeSegment(ctx.params.project, "project");
|
|
14211
|
+
}
|
|
14212
|
+
/**
|
|
14213
|
+
* PUT /api/v1/projects/:project/perspectives — body is the perspectives
|
|
14214
|
+
* document as JSON. Schema validation is the CLI's job (it Zod-validates
|
|
14215
|
+
* before pushing); the hub only rejects bodies that aren't a JSON object,
|
|
14216
|
+
* so the UI can always JSON.parse what it reads back.
|
|
14217
|
+
*/
|
|
14218
|
+
function createPutPerspectivesHandler(config) {
|
|
14219
|
+
return async (ctx) => {
|
|
14220
|
+
const project = requireProject(ctx);
|
|
14221
|
+
const body = await readBody(ctx.req, MAX_PERSPECTIVES_BODY_BYTES);
|
|
14222
|
+
let parsed;
|
|
14223
|
+
try {
|
|
14224
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
14225
|
+
} catch {
|
|
14226
|
+
throw new HttpError(400, "invalid_body", "perspectives body must be valid JSON");
|
|
14227
|
+
}
|
|
14228
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "invalid_body", "perspectives body must be a JSON object");
|
|
14229
|
+
await config.store.put(project, new Uint8Array(body));
|
|
14230
|
+
ctx.res.statusCode = 204;
|
|
14231
|
+
ctx.res.end();
|
|
14232
|
+
};
|
|
14233
|
+
}
|
|
14234
|
+
/** GET /api/v1/projects/:project/perspectives — the stored document, or 404. */
|
|
14235
|
+
function createGetPerspectivesHandler(config) {
|
|
14236
|
+
return async (ctx) => {
|
|
14237
|
+
const project = requireProject(ctx);
|
|
14238
|
+
const stored = await config.store.get(project);
|
|
14239
|
+
if (!stored) throw new HttpError(404, "not_found", `no perspectives stored for project "${project}"`);
|
|
14240
|
+
sendBytes(ctx.res, 200, stored, "application/json; charset=utf-8");
|
|
14241
|
+
};
|
|
14242
|
+
}
|
|
14243
|
+
/** DELETE /api/v1/projects/:project/perspectives */
|
|
14244
|
+
function createDeletePerspectivesHandler(config) {
|
|
14245
|
+
return async (ctx) => {
|
|
14246
|
+
const project = requireProject(ctx);
|
|
14247
|
+
await config.store.delete(project);
|
|
14248
|
+
ctx.res.statusCode = 204;
|
|
14249
|
+
ctx.res.end();
|
|
14250
|
+
};
|
|
14251
|
+
}
|
|
14252
|
+
const MAX_NOTE_BODY_BYTES = 64 * 1024;
|
|
14253
|
+
function parseNotePatch(body) {
|
|
14254
|
+
let parsed;
|
|
14255
|
+
try {
|
|
14256
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
14257
|
+
} catch {
|
|
14258
|
+
throw new HttpError(400, "invalid_body", "note patch must be valid JSON");
|
|
14259
|
+
}
|
|
14260
|
+
const rec = parsed;
|
|
14261
|
+
if (typeof rec?.feature !== "string" || rec.feature.length === 0 || typeof rec.spec !== "string" || rec.spec.length === 0 || typeof rec.note !== "string") throw new HttpError(400, "invalid_body", "note patch must be { feature, spec, note } with string values");
|
|
14262
|
+
return {
|
|
14263
|
+
feature: rec.feature,
|
|
14264
|
+
spec: rec.spec,
|
|
14265
|
+
note: rec.note
|
|
14266
|
+
};
|
|
14267
|
+
}
|
|
14268
|
+
/**
|
|
14269
|
+
* PATCH /api/v1/projects/:project/perspectives — the hub UI's note editing.
|
|
14270
|
+
* Body: `{ feature, spec, note }`; an empty `note` clears the field. The
|
|
14271
|
+
* note is the only human-authored field in the document, and this is its
|
|
14272
|
+
* only write path now that the document never lives in the consuming repo.
|
|
14273
|
+
* Runs as a serialized read-modify-write so an edit can't clobber (or be
|
|
14274
|
+
* clobbered by) a concurrent one.
|
|
14275
|
+
*/
|
|
14276
|
+
function createPatchPerspectivesNoteHandler(config) {
|
|
14277
|
+
return async (ctx) => {
|
|
14278
|
+
const project = requireProject(ctx);
|
|
14279
|
+
const patch = parseNotePatch(await readBody(ctx.req, MAX_NOTE_BODY_BYTES));
|
|
14280
|
+
await config.store.update(project, (current) => {
|
|
14281
|
+
if (current === null) throw new HttpError(404, "not_found", `no perspectives stored for project "${project}"`);
|
|
14282
|
+
const specEntry = current.features?.find((f) => f?.featureName === patch.feature)?.specs?.find((s) => s?.specName === patch.spec);
|
|
14283
|
+
if (!specEntry) throw new HttpError(404, "not_found", `no spec "${patch.feature}/${patch.spec}" in the perspectives document`);
|
|
14284
|
+
if (patch.note === "") delete specEntry.note;
|
|
14285
|
+
else specEntry.note = patch.note;
|
|
14286
|
+
return current;
|
|
14287
|
+
});
|
|
14288
|
+
ctx.res.statusCode = 204;
|
|
14289
|
+
ctx.res.end();
|
|
14290
|
+
};
|
|
14291
|
+
}
|
|
14292
|
+
//#endregion
|
|
14189
14293
|
//#region src/hub/api/handlers/learning-jobs.ts
|
|
14190
14294
|
const MAX_BODY_BYTES = 4 * 1024;
|
|
14191
14295
|
/** POST /api/v1/projects/:project/learning-jobs — create + enqueue a triage-learning job. */
|
|
@@ -14569,6 +14673,7 @@ const HTML_BODY = `
|
|
|
14569
14673
|
<div class="nav-group" id="sidebar-project">no project</div>
|
|
14570
14674
|
<nav class="nav">
|
|
14571
14675
|
<a href="#/runs" class="nav-runs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg> <span data-i18n="nav.runs">Runs</span></a>
|
|
14676
|
+
<a href="#/perspectives" class="nav-perspectives"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg> <span data-i18n="nav.perspectives">Perspectives</span></a>
|
|
14572
14677
|
<a href="#/secrets" class="nav-secrets"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <span data-i18n="nav.secrets">Secrets</span></a>
|
|
14573
14678
|
<a href="#/prompts" class="nav-prompts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z"/><path d="M14 4v6h6"/><path d="M8 13h6M8 17h6"/></svg> <span data-i18n="nav.prompts">Prompts</span></a>
|
|
14574
14679
|
<a href="#/jobs" class="nav-jobs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg> <span data-i18n="nav.learning">Learning</span></a>
|
|
@@ -14617,6 +14722,34 @@ const HTML_BODY = `
|
|
|
14617
14722
|
</div>
|
|
14618
14723
|
</section>
|
|
14619
14724
|
|
|
14725
|
+
<!-- ===== PERSPECTIVES ===== -->
|
|
14726
|
+
<section id="view-perspectives" hidden>
|
|
14727
|
+
<div class="page-bar">
|
|
14728
|
+
<h1 data-i18n="perspectives.title">Perspectives</h1>
|
|
14729
|
+
<span class="updated" id="persp-updated"></span>
|
|
14730
|
+
<div class="spacer"></div>
|
|
14731
|
+
<button class="btn ghost sm" id="persp-refresh" data-i18n="common.refresh">Refresh</button>
|
|
14732
|
+
</div>
|
|
14733
|
+
<div class="content">
|
|
14734
|
+
<p id="persp-status" class="empty-note" hidden></p>
|
|
14735
|
+
<div id="persp-body" hidden>
|
|
14736
|
+
<div class="ov" id="persp-ov"></div>
|
|
14737
|
+
<div class="toolbar">
|
|
14738
|
+
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="persp-q" type="search" data-i18n-ph="perspectives.search" aria-label="Search cases"></label>
|
|
14739
|
+
<button class="fchip" data-f="all" aria-pressed="true" type="button" data-i18n="perspectives.filter.all">All</button>
|
|
14740
|
+
<button class="fchip" data-f="deterministic" aria-pressed="false" type="button" data-i18n="perspectives.filter.deterministic">Deterministic</button>
|
|
14741
|
+
<button class="fchip" data-f="live" aria-pressed="false" type="button" data-i18n="perspectives.filter.live">Live</button>
|
|
14742
|
+
<button class="fchip" data-f="norec" aria-pressed="false" type="button" data-i18n="perspectives.filter.norec">Not recorded only</button>
|
|
14743
|
+
</div>
|
|
14744
|
+
<div class="tblcard"><div class="table-wrap"><table>
|
|
14745
|
+
<thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th data-i18n="perspectives.col.status">Status</th><th></th></tr></thead>
|
|
14746
|
+
<tbody id="persp-tbody"></tbody>
|
|
14747
|
+
</table></div></div>
|
|
14748
|
+
<p class="empty-note" id="persp-no-hit" hidden data-i18n="perspectives.noHit">No matching cases.</p>
|
|
14749
|
+
</div>
|
|
14750
|
+
</div>
|
|
14751
|
+
</section>
|
|
14752
|
+
|
|
14620
14753
|
<!-- ===== RUN DETAIL ===== -->
|
|
14621
14754
|
<section id="view-detail" hidden>
|
|
14622
14755
|
<div class="page-bar">
|
|
@@ -15240,6 +15373,65 @@ const CSS = `
|
|
|
15240
15373
|
.prompt-diff pre { margin: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2);
|
|
15241
15374
|
font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--fg-dim); white-space: pre-wrap; word-break: break-word; max-height: 480px; overflow-y: auto; }
|
|
15242
15375
|
|
|
15376
|
+
/* perspectives — coverage strip, filter toolbar, and the one-table-per-project
|
|
15377
|
+
view (feature section rows + expandable case detail rows). Reuses the
|
|
15378
|
+
existing badge/chip primitives above; --pass/--amber cover the "runnable"
|
|
15379
|
+
vs "not recorded" coloring so no new tokens are needed. */
|
|
15380
|
+
.ov { display: flex; align-items: center; gap: 28px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; flex-wrap: wrap; }
|
|
15381
|
+
.ov .num { display: flex; flex-direction: column; }
|
|
15382
|
+
.ov .num b { font-size: 24px; line-height: 1.15; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
15383
|
+
.ov .num span { font-size: 12px; color: var(--muted); }
|
|
15384
|
+
.ov .sep { width: 1px; align-self: stretch; background: var(--border); }
|
|
15385
|
+
.ov .breakdown { display: flex; gap: 20px; }
|
|
15386
|
+
.covwrap { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 6px; }
|
|
15387
|
+
.covbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
|
|
15388
|
+
.covbar .ok { background: var(--pass); }
|
|
15389
|
+
.covbar .no { background: var(--amber); opacity: 0.75; }
|
|
15390
|
+
.covleg { display: flex; gap: 16px; font-size: 12px; color: var(--muted); }
|
|
15391
|
+
.covleg i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
|
|
15392
|
+
.covleg .lg-ok i { background: var(--pass); }
|
|
15393
|
+
.covleg .lg-no i { background: var(--amber); opacity: 0.75; }
|
|
15394
|
+
|
|
15395
|
+
.search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
|
|
15396
|
+
.search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
|
|
15397
|
+
.search input { border: none; outline: none; font: inherit; font-size: 13px; width: 100%; background: transparent; color: var(--fg); }
|
|
15398
|
+
.fchip { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: var(--muted); }
|
|
15399
|
+
.fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
|
15400
|
+
|
|
15401
|
+
.chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
|
|
15402
|
+
.badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
15403
|
+
.badge.ok .d { background: var(--pass); }
|
|
15404
|
+
.badge.norec { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
15405
|
+
.badge.norec .d { background: var(--amber); }
|
|
15406
|
+
|
|
15407
|
+
.tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
15408
|
+
/* Feature section rows must read as headings, not as just another data row —
|
|
15409
|
+
larger, darker, extra padding, and a strong top rule marking the break. */
|
|
15410
|
+
tr.grp td { background: var(--surface-2); border-top: 2px solid var(--border-strong); border-bottom: 1px solid var(--border); padding: 12px 12px 10px; font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--fg); }
|
|
15411
|
+
tr.grp td .gcount { color: var(--muted); font-weight: 500; font-size: 12px; font-family: var(--font); margin-left: 10px; }
|
|
15412
|
+
td.c-title { font-weight: 500; max-width: 460px; }
|
|
15413
|
+
td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; margin-top: 1px; }
|
|
15414
|
+
td.c-chev { width: 28px; color: var(--muted-2); text-align: right; }
|
|
15415
|
+
.chev-i { display: inline-block; transition: transform 0.15s; font-size: 11px; }
|
|
15416
|
+
tr.row[aria-expanded="true"] .chev-i { transform: rotate(90deg); }
|
|
15417
|
+
tr.detail { display: none; }
|
|
15418
|
+
tr.detail.open { display: table-row; }
|
|
15419
|
+
tr.detail > td { background: var(--surface-2); padding: 14px 16px 16px; }
|
|
15420
|
+
|
|
15421
|
+
.d-grid { display: grid; grid-template-columns: 120px 1fr; gap: 7px 14px; font-size: 13px; max-width: 860px; }
|
|
15422
|
+
.d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
|
|
15423
|
+
.d-grid dd { color: var(--fg-dim); }
|
|
15424
|
+
.d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
|
|
15425
|
+
.d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
|
|
15426
|
+
.d-grid code { font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
|
|
15427
|
+
.notebox { margin-top: 12px; max-width: 860px; }
|
|
15428
|
+
.notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
15429
|
+
.notebox textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
|
|
15430
|
+
.notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
|
|
15431
|
+
.notebox .nstatus { font-size: 12px; color: var(--muted); }
|
|
15432
|
+
.notebox .nstatus.ok { color: var(--pass); }
|
|
15433
|
+
.notebox .nstatus.err { color: var(--fail); }
|
|
15434
|
+
|
|
15243
15435
|
@media (max-width: 900px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } .logo .wm { display: none; } .split { grid-template-columns: 1fr; } .rd-head .meta { margin-left: 0; } }
|
|
15244
15436
|
@media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
|
|
15245
15437
|
`;
|
|
@@ -15266,7 +15458,7 @@ const CLIENT_JS = `
|
|
|
15266
15458
|
// only their display text is localized via FAILURE_LABEL_JA.
|
|
15267
15459
|
var I18N = {
|
|
15268
15460
|
en: {
|
|
15269
|
-
"nav.projects": "Projects", "nav.runs": "Runs", "nav.secrets": "Secrets",
|
|
15461
|
+
"nav.projects": "Projects", "nav.runs": "Runs", "nav.perspectives": "Perspectives", "nav.secrets": "Secrets",
|
|
15270
15462
|
"nav.prompts": "Prompts", "nav.learning": "Learning",
|
|
15271
15463
|
"app.project": "project", "app.profile": "profile", "app.disconnect": "Disconnect", "app.noProject": "no project",
|
|
15272
15464
|
"app.newProfile": "New profile",
|
|
@@ -15308,6 +15500,27 @@ const CLIENT_JS = `
|
|
|
15308
15500
|
"learn.cta.desc": "Learn from what you graded so ccqa classifies failure causes the same way next time.",
|
|
15309
15501
|
"learn.cta.run": "Learn",
|
|
15310
15502
|
"secrets.title": "Secrets", "prompts.title": "Prompts", "learning.title": "Learning",
|
|
15503
|
+
"perspectives.title": "Perspectives",
|
|
15504
|
+
"perspectives.search": "Search cases…",
|
|
15505
|
+
"perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
|
|
15506
|
+
"perspectives.filter.live": "Live", "perspectives.filter.norec": "Not recorded only",
|
|
15507
|
+
"perspectives.col.case": "Case", "perspectives.col.mode": "Mode", "perspectives.col.status": "Status",
|
|
15508
|
+
"perspectives.noHit": "No matching cases.",
|
|
15509
|
+
"perspectives.updated": "Last updated:",
|
|
15510
|
+
"perspectives.empty": "No perspectives yet. Run ccqa perspectives, or record a test — it's created automatically.",
|
|
15511
|
+
"perspectives.loadFailed": "Loading perspectives failed",
|
|
15512
|
+
"perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
|
|
15513
|
+
"perspectives.status.runnable": "runnable", "perspectives.status.notRecorded": "not recorded",
|
|
15514
|
+
"perspectives.metric.features": "Features", "perspectives.metric.cases": "Test cases",
|
|
15515
|
+
"perspectives.metric.deterministic": "Deterministic", "perspectives.metric.live": "Live",
|
|
15516
|
+
"perspectives.cov.runnable": "runnable", "perspectives.cov.notRecorded": "not recorded",
|
|
15517
|
+
"perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
|
|
15518
|
+
"perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
|
|
15519
|
+
"perspectives.d.relatedPaths": "Related code",
|
|
15520
|
+
"perspectives.note.label": "Note",
|
|
15521
|
+
"perspectives.note.placeholder": "Notes about this case…",
|
|
15522
|
+
"perspectives.note.saved": "Saved",
|
|
15523
|
+
"perspectives.note.error": "Could not save — retry",
|
|
15311
15524
|
"prompt.card.record": "Recording browser actions",
|
|
15312
15525
|
"prompt.card.live": "Live run (AI-driven)",
|
|
15313
15526
|
"prompt.card.customPrompt": "Failure-cause classification",
|
|
@@ -15335,7 +15548,7 @@ const CLIENT_JS = `
|
|
|
15335
15548
|
"jobs.failed": "The learning job failed.", "jobs.newCustomPrompt": "New custom prompt:", "jobs.empty": "No learning jobs yet. Grade failing specs on a run, then Learn."
|
|
15336
15549
|
},
|
|
15337
15550
|
ja: {
|
|
15338
|
-
"nav.projects": "プロジェクト", "nav.runs": "実行", "nav.secrets": "シークレット",
|
|
15551
|
+
"nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "Perspectives", "nav.secrets": "シークレット",
|
|
15339
15552
|
"nav.prompts": "プロンプト", "nav.learning": "学習",
|
|
15340
15553
|
"app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
|
|
15341
15554
|
"app.newProfile": "新規プロファイル",
|
|
@@ -15377,6 +15590,27 @@ const CLIENT_JS = `
|
|
|
15377
15590
|
"learn.cta.desc": "採点した内容をもとに、ccqaが次回から同じように失敗の原因を分類できるよう学習します。",
|
|
15378
15591
|
"learn.cta.run": "学習",
|
|
15379
15592
|
"secrets.title": "シークレット", "prompts.title": "プロンプト", "learning.title": "学習",
|
|
15593
|
+
"perspectives.title": "Perspectives",
|
|
15594
|
+
"perspectives.search": "ケースを検索…",
|
|
15595
|
+
"perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
|
|
15596
|
+
"perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
|
|
15597
|
+
"perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
|
|
15598
|
+
"perspectives.noHit": "該当するケースがありません。",
|
|
15599
|
+
"perspectives.updated": "最終更新:",
|
|
15600
|
+
"perspectives.empty": "まだperspectivesがありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
|
|
15601
|
+
"perspectives.loadFailed": "perspectivesの読み込みに失敗しました",
|
|
15602
|
+
"perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
|
|
15603
|
+
"perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
|
|
15604
|
+
"perspectives.metric.features": "機能", "perspectives.metric.cases": "テストケース",
|
|
15605
|
+
"perspectives.metric.deterministic": "決定的", "perspectives.metric.live": "ライブ",
|
|
15606
|
+
"perspectives.cov.runnable": "実行可能", "perspectives.cov.notRecorded": "未record",
|
|
15607
|
+
"perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
|
|
15608
|
+
"perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
|
|
15609
|
+
"perspectives.d.relatedPaths": "関連コード",
|
|
15610
|
+
"perspectives.note.label": "note",
|
|
15611
|
+
"perspectives.note.placeholder": "このケースについてのメモ…",
|
|
15612
|
+
"perspectives.note.saved": "保存しました",
|
|
15613
|
+
"perspectives.note.error": "保存に失敗しました — 再試行してください",
|
|
15380
15614
|
"prompt.card.record": "ブラウザ操作の記録",
|
|
15381
15615
|
"prompt.card.live": "ライブ実行(AI操作)",
|
|
15382
15616
|
"prompt.card.customPrompt": "失敗原因の分類",
|
|
@@ -15632,8 +15866,8 @@ const CLIENT_JS = `
|
|
|
15632
15866
|
|
|
15633
15867
|
// ── view routing ────────────────────────────────────────────────────
|
|
15634
15868
|
|
|
15635
|
-
var VIEWS = ["projects", "runs", "detail", "secrets", "prompts", "jobs"];
|
|
15636
|
-
var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs" };
|
|
15869
|
+
var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
|
|
15870
|
+
var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs", perspectives: ".nav-perspectives" };
|
|
15637
15871
|
function showView(id) {
|
|
15638
15872
|
// Any in-flight job poll belongs to the view we're leaving — bump the token
|
|
15639
15873
|
// so its next tick is a no-op (see pollJob).
|
|
@@ -15651,6 +15885,7 @@ const CLIENT_JS = `
|
|
|
15651
15885
|
function updateNavGate() {
|
|
15652
15886
|
var gated = !state.project;
|
|
15653
15887
|
document.querySelector(".nav-runs").classList.toggle("disabled", gated);
|
|
15888
|
+
document.querySelector(".nav-perspectives").classList.toggle("disabled", gated);
|
|
15654
15889
|
document.querySelector(".nav-secrets").classList.toggle("disabled", gated);
|
|
15655
15890
|
document.querySelector(".nav-prompts").classList.toggle("disabled", gated);
|
|
15656
15891
|
document.querySelector(".nav-jobs").classList.toggle("disabled", gated);
|
|
@@ -15666,6 +15901,7 @@ const CLIENT_JS = `
|
|
|
15666
15901
|
if (location.hash === "#/projects" || !state.project) { openProjects(); return; }
|
|
15667
15902
|
var m = location.hash.match(/^#\\/runs\\/(.+)$/);
|
|
15668
15903
|
if (m) { openRunDetail(decodeURIComponent(m[1])); return; }
|
|
15904
|
+
if (location.hash === "#/perspectives") { openPerspectives(); return; }
|
|
15669
15905
|
if (location.hash === "#/secrets") { openSecrets(); return; }
|
|
15670
15906
|
if (location.hash === "#/prompts") { openPrompts(); return; }
|
|
15671
15907
|
var j = location.hash.match(/^#\\/jobs\\/(.+)$/);
|
|
@@ -16922,6 +17158,305 @@ const CLIENT_JS = `
|
|
|
16922
17158
|
loadPrompts();
|
|
16923
17159
|
}
|
|
16924
17160
|
|
|
17161
|
+
// ── perspectives ──────────────────────────────────────────────────────
|
|
17162
|
+
// A read-mostly coverage inventory the CLI generates ("ccqa perspectives" /
|
|
17163
|
+
// record); the hub UI's only write path is the per-case note (PATCH). The
|
|
17164
|
+
// whole document is fetched once per view-open and filtered/rendered
|
|
17165
|
+
// client-side — small enough that there is no pagination.
|
|
17166
|
+
|
|
17167
|
+
var perspState = { doc: null, q: "", f: "all" };
|
|
17168
|
+
|
|
17169
|
+
function perspectivesPath() {
|
|
17170
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) + "/perspectives";
|
|
17171
|
+
}
|
|
17172
|
+
|
|
17173
|
+
// GET the perspectives document. Resolves null on 404 (no document stored
|
|
17174
|
+
// yet — the normal "nothing recorded" state) instead of throwing, so
|
|
17175
|
+
// loadPerspectives can tell that apart from a real fetch/parse failure.
|
|
17176
|
+
function fetchPerspectives() {
|
|
17177
|
+
return fetch(perspectivesPath(), { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
|
|
17178
|
+
if (res.status === 404) return null;
|
|
17179
|
+
if (!res.ok) throw new Error(res.status + " " + res.statusText);
|
|
17180
|
+
return res.json();
|
|
17181
|
+
}, function () { throw new Error("Network unreachable — check the hub URL and your connection"); });
|
|
17182
|
+
}
|
|
17183
|
+
|
|
17184
|
+
// The execution mode lives inside the mechanically-derived status object
|
|
17185
|
+
// (spec.status.mode), not at the top level of a spec entry.
|
|
17186
|
+
function perspMode(spec) {
|
|
17187
|
+
return spec.status && spec.status.mode === "live" ? "live" : "deterministic";
|
|
17188
|
+
}
|
|
17189
|
+
|
|
17190
|
+
function perspRunnable(spec) {
|
|
17191
|
+
return perspMode(spec) === "live" || (spec.status && spec.status.generated === true);
|
|
17192
|
+
}
|
|
17193
|
+
|
|
17194
|
+
function setPerspStatus(message) {
|
|
17195
|
+
var box = document.getElementById("persp-status");
|
|
17196
|
+
box.hidden = !message;
|
|
17197
|
+
box.textContent = message || "";
|
|
17198
|
+
}
|
|
17199
|
+
|
|
17200
|
+
function perspModeChip(mode) {
|
|
17201
|
+
var span = el("span", "chip" + (mode === "live" ? " live" : ""));
|
|
17202
|
+
span.textContent = t(mode === "live" ? "perspectives.mode.live" : "perspectives.mode.deterministic");
|
|
17203
|
+
return span;
|
|
17204
|
+
}
|
|
17205
|
+
|
|
17206
|
+
function perspStatusBadge(spec) {
|
|
17207
|
+
var ok = perspRunnable(spec);
|
|
17208
|
+
var span = el("span", "badge " + (ok ? "ok" : "norec"));
|
|
17209
|
+
span.appendChild(el("span", "d"));
|
|
17210
|
+
span.appendChild(document.createTextNode(" " + t(ok ? "perspectives.status.runnable" : "perspectives.status.notRecorded")));
|
|
17211
|
+
return span;
|
|
17212
|
+
}
|
|
17213
|
+
|
|
17214
|
+
// Overview strip: feature/case counts, deterministic/live breakdown, and a
|
|
17215
|
+
// runnable-vs-not-recorded coverage bar.
|
|
17216
|
+
function renderPerspOverview(doc) {
|
|
17217
|
+
var host = document.getElementById("persp-ov");
|
|
17218
|
+
clear(host);
|
|
17219
|
+
var allSpecs = doc.features.reduce(function (acc, f) { return acc.concat(f.specs); }, []);
|
|
17220
|
+
var total = allSpecs.length;
|
|
17221
|
+
var ok = allSpecs.filter(perspRunnable).length;
|
|
17222
|
+
var no = total - ok;
|
|
17223
|
+
var det = allSpecs.filter(function (s) { return perspMode(s) === "deterministic"; }).length;
|
|
17224
|
+
var live = total - det;
|
|
17225
|
+
|
|
17226
|
+
function numBlock(value, labelKey) {
|
|
17227
|
+
var box = el("div", "num");
|
|
17228
|
+
box.appendChild(el("b", null, String(value)));
|
|
17229
|
+
box.appendChild(el("span", null, t(labelKey)));
|
|
17230
|
+
return box;
|
|
17231
|
+
}
|
|
17232
|
+
|
|
17233
|
+
host.appendChild(numBlock(doc.features.length, "perspectives.metric.features"));
|
|
17234
|
+
host.appendChild(el("div", "sep"));
|
|
17235
|
+
host.appendChild(numBlock(total, "perspectives.metric.cases"));
|
|
17236
|
+
host.appendChild(el("div", "sep"));
|
|
17237
|
+
var breakdown = el("div", "breakdown");
|
|
17238
|
+
breakdown.appendChild(numBlock(det, "perspectives.metric.deterministic"));
|
|
17239
|
+
breakdown.appendChild(numBlock(live, "perspectives.metric.live"));
|
|
17240
|
+
host.appendChild(breakdown);
|
|
17241
|
+
|
|
17242
|
+
var covwrap = el("div", "covwrap");
|
|
17243
|
+
var covbar = el("div", "covbar");
|
|
17244
|
+
if (total > 0) {
|
|
17245
|
+
var okBar = el("div", "ok");
|
|
17246
|
+
okBar.style.width = (ok / total) * 100 + "%";
|
|
17247
|
+
var noBar = el("div", "no");
|
|
17248
|
+
noBar.style.width = (no / total) * 100 + "%";
|
|
17249
|
+
covbar.appendChild(okBar);
|
|
17250
|
+
covbar.appendChild(noBar);
|
|
17251
|
+
}
|
|
17252
|
+
covwrap.appendChild(covbar);
|
|
17253
|
+
var covleg = el("div", "covleg");
|
|
17254
|
+
var legOk = el("span", "lg-ok");
|
|
17255
|
+
legOk.appendChild(el("i"));
|
|
17256
|
+
legOk.appendChild(document.createTextNode(t("perspectives.cov.runnable") + " " + ok));
|
|
17257
|
+
covleg.appendChild(legOk);
|
|
17258
|
+
if (no > 0) {
|
|
17259
|
+
var legNo = el("span", "lg-no");
|
|
17260
|
+
legNo.appendChild(el("i"));
|
|
17261
|
+
legNo.appendChild(document.createTextNode(t("perspectives.cov.notRecorded") + " " + no));
|
|
17262
|
+
covleg.appendChild(legNo);
|
|
17263
|
+
}
|
|
17264
|
+
covwrap.appendChild(covleg);
|
|
17265
|
+
host.appendChild(covwrap);
|
|
17266
|
+
}
|
|
17267
|
+
|
|
17268
|
+
function perspMatches(spec) {
|
|
17269
|
+
if (perspState.f === "deterministic" && perspMode(spec) !== "deterministic") return false;
|
|
17270
|
+
if (perspState.f === "live" && perspMode(spec) !== "live") return false;
|
|
17271
|
+
if (perspState.f === "norec" && perspRunnable(spec)) return false;
|
|
17272
|
+
if (perspState.q) {
|
|
17273
|
+
var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
|
|
17274
|
+
if (hay.indexOf(perspState.q) === -1) return false;
|
|
17275
|
+
}
|
|
17276
|
+
return true;
|
|
17277
|
+
}
|
|
17278
|
+
|
|
17279
|
+
// Detail row: a definition list of the case's fields plus the note editor.
|
|
17280
|
+
// Built with createElement/textContent throughout — every field here is
|
|
17281
|
+
// API-derived, so none of it may go through innerHTML.
|
|
17282
|
+
function perspDetailContent(feature, spec) {
|
|
17283
|
+
var frag = document.createDocumentFragment();
|
|
17284
|
+
var dl = el("dl", "d-grid");
|
|
17285
|
+
function row(labelKey, valueNode) {
|
|
17286
|
+
dl.appendChild(el("dt", null, t(labelKey)));
|
|
17287
|
+
var dd = el("dd");
|
|
17288
|
+
if (typeof valueNode === "string") dd.textContent = valueNode;
|
|
17289
|
+
else dd.appendChild(valueNode);
|
|
17290
|
+
dl.appendChild(dd);
|
|
17291
|
+
}
|
|
17292
|
+
if (spec.preconditions && spec.preconditions.length) {
|
|
17293
|
+
var ul = el("ul");
|
|
17294
|
+
spec.preconditions.forEach(function (p) { ul.appendChild(el("li", null, p)); });
|
|
17295
|
+
row("perspectives.d.preconditions", ul);
|
|
17296
|
+
}
|
|
17297
|
+
if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
|
|
17298
|
+
if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
|
|
17299
|
+
var specCode = el("code", null, spec.specName);
|
|
17300
|
+
row("perspectives.d.spec", specCode);
|
|
17301
|
+
if (spec.relatedPaths && spec.relatedPaths.length) {
|
|
17302
|
+
var pathsWrap = el("span");
|
|
17303
|
+
spec.relatedPaths.forEach(function (p, i) {
|
|
17304
|
+
if (i > 0) pathsWrap.appendChild(document.createTextNode(" "));
|
|
17305
|
+
pathsWrap.appendChild(el("code", null, p));
|
|
17306
|
+
});
|
|
17307
|
+
row("perspectives.d.relatedPaths", pathsWrap);
|
|
17308
|
+
}
|
|
17309
|
+
frag.appendChild(dl);
|
|
17310
|
+
|
|
17311
|
+
var notebox = el("div", "notebox");
|
|
17312
|
+
notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
|
|
17313
|
+
var ta = el("textarea");
|
|
17314
|
+
ta.placeholder = t("perspectives.note.placeholder");
|
|
17315
|
+
ta.value = spec.note || "";
|
|
17316
|
+
notebox.appendChild(ta);
|
|
17317
|
+
var nact = el("div", "nact");
|
|
17318
|
+
var saveBtn = el("button", "btn primary", t("common.save"));
|
|
17319
|
+
saveBtn.type = "button";
|
|
17320
|
+
var statusEl = el("span", "nstatus");
|
|
17321
|
+
nact.appendChild(saveBtn);
|
|
17322
|
+
nact.appendChild(statusEl);
|
|
17323
|
+
notebox.appendChild(nact);
|
|
17324
|
+
frag.appendChild(notebox);
|
|
17325
|
+
|
|
17326
|
+
saveBtn.addEventListener("click", function () {
|
|
17327
|
+
saveBtn.disabled = true;
|
|
17328
|
+
statusEl.className = "nstatus";
|
|
17329
|
+
statusEl.textContent = "";
|
|
17330
|
+
apiFetch(perspectivesPath(), {
|
|
17331
|
+
method: "PATCH",
|
|
17332
|
+
headers: { "Content-Type": "application/json" },
|
|
17333
|
+
body: JSON.stringify({ feature: feature.featureName, spec: spec.specName, note: ta.value }),
|
|
17334
|
+
}).then(function () {
|
|
17335
|
+
spec.note = ta.value || undefined;
|
|
17336
|
+
statusEl.className = "nstatus ok";
|
|
17337
|
+
statusEl.textContent = t("perspectives.note.saved");
|
|
17338
|
+
}).catch(function (err) {
|
|
17339
|
+
statusEl.className = "nstatus err";
|
|
17340
|
+
statusEl.textContent = t("perspectives.note.error") + ": " + err.message;
|
|
17341
|
+
}).then(function () { saveBtn.disabled = false; });
|
|
17342
|
+
});
|
|
17343
|
+
|
|
17344
|
+
return frag;
|
|
17345
|
+
}
|
|
17346
|
+
|
|
17347
|
+
function renderPerspTable(doc) {
|
|
17348
|
+
var tbody = document.getElementById("persp-tbody");
|
|
17349
|
+
clear(tbody);
|
|
17350
|
+
var hits = 0;
|
|
17351
|
+
doc.features.forEach(function (feature) {
|
|
17352
|
+
var specs = feature.specs.filter(perspMatches);
|
|
17353
|
+
if (!specs.length) return;
|
|
17354
|
+
hits += specs.length;
|
|
17355
|
+
|
|
17356
|
+
var grpRow = el("tr", "grp");
|
|
17357
|
+
var grpTd = el("td");
|
|
17358
|
+
grpTd.colSpan = 4;
|
|
17359
|
+
grpTd.appendChild(document.createTextNode(feature.featureName));
|
|
17360
|
+
grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.metric.cases").toLowerCase()));
|
|
17361
|
+
grpRow.appendChild(grpTd);
|
|
17362
|
+
tbody.appendChild(grpRow);
|
|
17363
|
+
|
|
17364
|
+
specs.forEach(function (spec) {
|
|
17365
|
+
var row = el("tr", "row");
|
|
17366
|
+
row.tabIndex = 0;
|
|
17367
|
+
row.setAttribute("aria-expanded", "false");
|
|
17368
|
+
|
|
17369
|
+
var titleTd = el("td", "c-title");
|
|
17370
|
+
titleTd.appendChild(document.createTextNode(spec.title));
|
|
17371
|
+
if (spec.summary) titleTd.appendChild(el("span", "csum", spec.summary));
|
|
17372
|
+
row.appendChild(titleTd);
|
|
17373
|
+
|
|
17374
|
+
var modeTd = el("td");
|
|
17375
|
+
modeTd.appendChild(perspModeChip(perspMode(spec)));
|
|
17376
|
+
row.appendChild(modeTd);
|
|
17377
|
+
|
|
17378
|
+
var statusTd = el("td");
|
|
17379
|
+
statusTd.appendChild(perspStatusBadge(spec));
|
|
17380
|
+
row.appendChild(statusTd);
|
|
17381
|
+
|
|
17382
|
+
var chevTd = el("td", "c-chev");
|
|
17383
|
+
chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
|
|
17384
|
+
row.appendChild(chevTd);
|
|
17385
|
+
|
|
17386
|
+
var detailRow = el("tr", "detail");
|
|
17387
|
+
var detailTd = el("td");
|
|
17388
|
+
detailTd.colSpan = 4;
|
|
17389
|
+
detailRow.appendChild(detailTd);
|
|
17390
|
+
var built = false;
|
|
17391
|
+
|
|
17392
|
+
function toggle() {
|
|
17393
|
+
var open = detailRow.classList.toggle("open");
|
|
17394
|
+
row.setAttribute("aria-expanded", open ? "true" : "false");
|
|
17395
|
+
if (open && !built) {
|
|
17396
|
+
detailTd.appendChild(perspDetailContent(feature, spec));
|
|
17397
|
+
built = true;
|
|
17398
|
+
}
|
|
17399
|
+
}
|
|
17400
|
+
row.addEventListener("click", function (e) {
|
|
17401
|
+
if (e.target.tagName === "TEXTAREA" || e.target.tagName === "BUTTON") return;
|
|
17402
|
+
toggle();
|
|
17403
|
+
});
|
|
17404
|
+
row.addEventListener("keydown", function (e) {
|
|
17405
|
+
if ((e.key === "Enter" || e.key === " ") && e.target === row) { e.preventDefault(); toggle(); }
|
|
17406
|
+
});
|
|
17407
|
+
|
|
17408
|
+
tbody.appendChild(row);
|
|
17409
|
+
tbody.appendChild(detailRow);
|
|
17410
|
+
});
|
|
17411
|
+
});
|
|
17412
|
+
document.getElementById("persp-no-hit").hidden = hits > 0;
|
|
17413
|
+
}
|
|
17414
|
+
|
|
17415
|
+
function renderPerspectives() {
|
|
17416
|
+
var doc = perspState.doc;
|
|
17417
|
+
if (!doc) return;
|
|
17418
|
+
renderPerspOverview(doc);
|
|
17419
|
+
renderPerspTable(doc);
|
|
17420
|
+
}
|
|
17421
|
+
|
|
17422
|
+
function setPerspUpdated(doc) {
|
|
17423
|
+
var span = document.getElementById("persp-updated");
|
|
17424
|
+
if (doc && doc.generatedAt) {
|
|
17425
|
+
span.hidden = false;
|
|
17426
|
+
span.textContent = t("perspectives.updated") + " " + relTime(doc.generatedAt);
|
|
17427
|
+
} else {
|
|
17428
|
+
span.hidden = true;
|
|
17429
|
+
span.textContent = "";
|
|
17430
|
+
}
|
|
17431
|
+
}
|
|
17432
|
+
|
|
17433
|
+
function loadPerspectives() {
|
|
17434
|
+
setPerspStatus("");
|
|
17435
|
+
document.getElementById("persp-body").hidden = true;
|
|
17436
|
+
setPerspUpdated(null);
|
|
17437
|
+
fetchPerspectives()
|
|
17438
|
+
.then(function (doc) {
|
|
17439
|
+
perspState.doc = doc;
|
|
17440
|
+
if (!doc) { setPerspStatus(t("perspectives.empty")); return; }
|
|
17441
|
+
setPerspUpdated(doc);
|
|
17442
|
+
document.getElementById("persp-body").hidden = false;
|
|
17443
|
+
renderPerspectives();
|
|
17444
|
+
})
|
|
17445
|
+
.catch(function (err) {
|
|
17446
|
+
perspState.doc = null;
|
|
17447
|
+
setPerspStatus(t("perspectives.loadFailed") + ": " + err.message);
|
|
17448
|
+
});
|
|
17449
|
+
}
|
|
17450
|
+
|
|
17451
|
+
function openPerspectives() {
|
|
17452
|
+
showView("perspectives");
|
|
17453
|
+
document.getElementById("persp-q").value = perspState.q;
|
|
17454
|
+
document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
|
|
17455
|
+
b.setAttribute("aria-pressed", String(b.getAttribute("data-f") === perspState.f));
|
|
17456
|
+
});
|
|
17457
|
+
loadPerspectives();
|
|
17458
|
+
}
|
|
17459
|
+
|
|
16925
17460
|
// The custom prompt body is JSON (schemaVersion/basePromptVersion/customPromptVersion/
|
|
16926
17461
|
// generatedAt/guidance); the textarea only ever shows the learned guidance
|
|
16927
17462
|
// text, never the raw JSON. A parse failure falls back to the raw text so a
|
|
@@ -17313,6 +17848,22 @@ const CLIENT_JS = `
|
|
|
17313
17848
|
// argument (which would render "[object PointerEvent]" in the status box).
|
|
17314
17849
|
document.getElementById("sec-load").addEventListener("click", function () { loadSecrets(); });
|
|
17315
17850
|
|
|
17851
|
+
// perspectives view
|
|
17852
|
+
document.getElementById("persp-refresh").addEventListener("click", function () { loadPerspectives(); });
|
|
17853
|
+
document.getElementById("persp-q").addEventListener("input", function (e) {
|
|
17854
|
+
perspState.q = e.target.value.trim().toLowerCase();
|
|
17855
|
+
renderPerspectives();
|
|
17856
|
+
});
|
|
17857
|
+
document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
|
|
17858
|
+
b.addEventListener("click", function () {
|
|
17859
|
+
perspState.f = b.getAttribute("data-f");
|
|
17860
|
+
document.querySelectorAll("#view-perspectives .fchip").forEach(function (x) {
|
|
17861
|
+
x.setAttribute("aria-pressed", x === b ? "true" : "false");
|
|
17862
|
+
});
|
|
17863
|
+
renderPerspectives();
|
|
17864
|
+
});
|
|
17865
|
+
});
|
|
17866
|
+
|
|
17316
17867
|
// prompts view
|
|
17317
17868
|
document.getElementById("pr-load").addEventListener("click", function () { loadPrompts(); });
|
|
17318
17869
|
|
|
@@ -17717,6 +18268,11 @@ function registerRoutes(router, config, queue) {
|
|
|
17717
18268
|
router.get("/api/v1/projects/:project/prompts", createListPromptsHandler(promptConfig));
|
|
17718
18269
|
router.get("/api/v1/projects/:project/prompts/:name", createGetPromptHandler(promptConfig));
|
|
17719
18270
|
router.delete("/api/v1/projects/:project/prompts/:name", createDeletePromptHandler(promptConfig));
|
|
18271
|
+
const perspectivesConfig = { store: storage.perspectives };
|
|
18272
|
+
router.put("/api/v1/projects/:project/perspectives", createPutPerspectivesHandler(perspectivesConfig));
|
|
18273
|
+
router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
|
|
18274
|
+
router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
|
|
18275
|
+
router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
|
|
17720
18276
|
router.post("/api/v1/projects/:project/learning-jobs", createCreateLearningJobHandler({
|
|
17721
18277
|
storage,
|
|
17722
18278
|
queue
|
|
@@ -17918,6 +18474,12 @@ function promptBlobPath(root, project, name) {
|
|
|
17918
18474
|
function promptMetaPath(root, project, name) {
|
|
17919
18475
|
return join(promptProjectDir(root, project), `${name}.meta.json`);
|
|
17920
18476
|
}
|
|
18477
|
+
function perspectivesKindDir(root) {
|
|
18478
|
+
return join(root, "perspectives");
|
|
18479
|
+
}
|
|
18480
|
+
function perspectivesPath(root, project) {
|
|
18481
|
+
return join(perspectivesKindDir(root), `${project}.json`);
|
|
18482
|
+
}
|
|
17921
18483
|
//#endregion
|
|
17922
18484
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
17923
18485
|
/**
|
|
@@ -18005,6 +18567,41 @@ function createFileJobStore(root) {
|
|
|
18005
18567
|
};
|
|
18006
18568
|
}
|
|
18007
18569
|
//#endregion
|
|
18570
|
+
//#region src/hub/core/storage/file/perspectives-store.ts
|
|
18571
|
+
/**
|
|
18572
|
+
* Defense-in-depth path validation: the HTTP layer already checks the project
|
|
18573
|
+
* segment, but this builds a file path from it, so it re-checks rather than
|
|
18574
|
+
* trusting callers. Mirrors the sibling prompt/secret stores.
|
|
18575
|
+
*/
|
|
18576
|
+
function assertSafeName$2(value, label) {
|
|
18577
|
+
if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
18578
|
+
}
|
|
18579
|
+
/**
|
|
18580
|
+
* Perspectives storage: one JSON document per project, plain UTF-8 with no
|
|
18581
|
+
* encryption (an inventory of what is tested is not a secret). No meta file —
|
|
18582
|
+
* the document's own `generatedAt` is its timestamp.
|
|
18583
|
+
*/
|
|
18584
|
+
function createFilePerspectivesStore(root) {
|
|
18585
|
+
return {
|
|
18586
|
+
async put(project, blob) {
|
|
18587
|
+
assertSafeName$2(project, "project");
|
|
18588
|
+
await writeBytes(perspectivesPath(root, project), blob);
|
|
18589
|
+
},
|
|
18590
|
+
async get(project) {
|
|
18591
|
+
assertSafeName$2(project, "project");
|
|
18592
|
+
return readBytesOrNull(perspectivesPath(root, project));
|
|
18593
|
+
},
|
|
18594
|
+
async update(project, mutate) {
|
|
18595
|
+
assertSafeName$2(project, "project");
|
|
18596
|
+
await updateJson(perspectivesPath(root, project), mutate);
|
|
18597
|
+
},
|
|
18598
|
+
async delete(project) {
|
|
18599
|
+
assertSafeName$2(project, "project");
|
|
18600
|
+
await removePath(perspectivesPath(root, project));
|
|
18601
|
+
}
|
|
18602
|
+
};
|
|
18603
|
+
}
|
|
18604
|
+
//#endregion
|
|
18008
18605
|
//#region src/hub/core/storage/file/prompt-store.ts
|
|
18009
18606
|
/**
|
|
18010
18607
|
* Defense-in-depth path validation: the HTTP layer already checks project/name,
|
|
@@ -18222,6 +18819,7 @@ function createFileHubStorage(dataDir) {
|
|
|
18222
18819
|
variables: createFileSecretStore(dataDir, "variables"),
|
|
18223
18820
|
triage: createFileTriageStore(dataDir),
|
|
18224
18821
|
prompts: createFilePromptStore(dataDir),
|
|
18822
|
+
perspectives: createFilePerspectivesStore(dataDir),
|
|
18225
18823
|
jobs: createFileJobStore(dataDir)
|
|
18226
18824
|
};
|
|
18227
18825
|
}
|