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