ccqa 0.3.9 → 0.3.10
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 +24 -287
- package/dist/bin/ccqa.mjs +857 -105
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -4,25 +4,30 @@ import { Command } from "commander";
|
|
|
4
4
|
import { accessSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
-
import { delimiter, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { delimiter, dirname, join, relative, resolve } from "node:path";
|
|
8
8
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
9
9
|
import matter from "gray-matter";
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
10
|
+
import { execFile, spawn } from "node:child_process";
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { createInterface as createInterface$1 } from "node:readline/promises";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
+
import { promisify } from "node:util";
|
|
15
16
|
//#region src/prompts/trace.ts
|
|
16
17
|
function generateSessionName() {
|
|
17
18
|
return `ccqa-trace-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
18
19
|
}
|
|
19
20
|
function buildTraceSystemPrompt(spec, options) {
|
|
21
|
+
return buildTraceSystemPromptInner(spec, options, true);
|
|
22
|
+
}
|
|
23
|
+
function buildTraceSystemPromptInner(spec, options, emitRelatedPaths) {
|
|
20
24
|
const sessionName = options?.sessionName ?? generateSessionName();
|
|
21
25
|
const skipCookiesClear = options?.skipCookiesClear ?? false;
|
|
22
26
|
const stepsText = spec.steps.map((step) => `### ${step.id}: ${step.title}
|
|
23
27
|
- **Instruction**: ${step.instruction}
|
|
24
28
|
- **Expected**: ${step.expected}`).join("\n\n");
|
|
25
29
|
const prereqText = spec.prerequisites ? `## Prerequisites\n${spec.prerequisites}\n\n` : "";
|
|
30
|
+
const relatedPathsBlock = emitRelatedPaths ? buildRelatedPathsInstruction() : "";
|
|
26
31
|
return `You are an expert QA engineer executing a browser E2E test. Execute each step precisely and record every browser action as a structured log line.
|
|
27
32
|
|
|
28
33
|
## Session
|
|
@@ -240,7 +245,7 @@ After each step (outside any code block):
|
|
|
240
245
|
ROUTE_STEP|<step-id>|<step-title>|ACTION:<what you did>|OBSERVATION:<what you verified>|STATUS:<PASSED|FAILED|SKIPPED>
|
|
241
246
|
\`\`\`
|
|
242
247
|
|
|
243
|
-
## Start
|
|
248
|
+
${relatedPathsBlock}## Start
|
|
244
249
|
|
|
245
250
|
${skipCookiesClear ? `A setup procedure has already been executed in this session. Do NOT clear cookies — keep the existing session state.
|
|
246
251
|
|
|
@@ -264,15 +269,49 @@ AB_ACTION|open|${spec.baseUrl}
|
|
|
264
269
|
|
|
265
270
|
Then emit \`STEP_START|step-01|...\` and begin.`;
|
|
266
271
|
}
|
|
272
|
+
function buildRelatedPathsInstruction() {
|
|
273
|
+
return `## Post-run: emit \`relatedPaths\` block
|
|
274
|
+
|
|
275
|
+
After all steps are complete (regardless of pass/fail) and **before** \`RUN_COMPLETED\`, you MUST emit a single \`RELATED_PATHS\` block. The host (not you) writes these paths into the spec's frontmatter — your only job is to emit the block.
|
|
276
|
+
|
|
277
|
+
\`relatedPaths\` is a list of glob patterns identifying the source files this spec depends on. CI uses them to decide whether a code change should trigger a drift check for this spec.
|
|
278
|
+
|
|
279
|
+
**Do NOT modify any source files.** You have only \`Read\`, \`Grep\`, and \`Glob\` for source inspection. The block you emit is the only output the host uses to update the spec.
|
|
280
|
+
|
|
281
|
+
**Inputs to consider:**
|
|
282
|
+
- The URLs you opened (\`AB_ACTION|open|...\`)
|
|
283
|
+
- The aria-labels, placeholders, and visible texts you clicked / filled / waited on
|
|
284
|
+
- The component / page / route files that render those strings (find them with \`Grep\`/\`Read\`/\`Glob\`)
|
|
285
|
+
|
|
286
|
+
**How to choose paths:**
|
|
287
|
+
1. For each URL the test navigates to, locate the route/page file and include it (e.g. \`src/app/tasks/page.tsx\`, \`src/pages/tasks/index.tsx\`).
|
|
288
|
+
2. For each unique aria-label / placeholder / visible text you interacted with, \`Grep\` the codebase, find the defining component, and include either the file or its parent feature directory.
|
|
289
|
+
3. Prefer **directory globs** (e.g. \`src/features/tasks/**\`) over individual files when several related components live in the same area. Otherwise list specific files.
|
|
290
|
+
4. Skip third-party files (\`node_modules/\`), build output (\`dist/\`, \`.next/\`), and generated code.
|
|
291
|
+
5. Be conservative — false positives (extra paths) are fine; false negatives (missing paths) cause drift to be missed in CI. When unsure whether a path is relevant, include it.
|
|
292
|
+
|
|
293
|
+
**Output format (STRICT — one line per path, no leading dashes, no commentary inside the block):**
|
|
294
|
+
|
|
295
|
+
\`\`\`
|
|
296
|
+
RELATED_PATHS_BEGIN
|
|
297
|
+
src/features/tasks/**
|
|
298
|
+
src/app/tasks/page.tsx
|
|
299
|
+
RELATED_PATHS_END
|
|
300
|
+
\`\`\`
|
|
301
|
+
|
|
302
|
+
Emit the block outside any other code block, on its own lines. If the test could not exercise the feature at all (e.g. blocked early), emit the block anyway with whatever paths you can identify; emit \`RELATED_PATHS_BEGIN\` immediately followed by \`RELATED_PATHS_END\` only if you genuinely could not identify any related file.
|
|
303
|
+
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
267
306
|
function buildTracePrompt(spec) {
|
|
268
307
|
return `Execute the test for "${spec.title}" at ${spec.baseUrl}.`;
|
|
269
308
|
}
|
|
270
309
|
function buildSetupTraceSystemPrompt(spec) {
|
|
271
|
-
return
|
|
310
|
+
return buildTraceSystemPromptInner({
|
|
272
311
|
title: spec.title,
|
|
273
312
|
baseUrl: "about:blank",
|
|
274
313
|
steps: spec.steps
|
|
275
|
-
});
|
|
314
|
+
}, void 0, false);
|
|
276
315
|
}
|
|
277
316
|
function buildSetupTracePrompt(spec) {
|
|
278
317
|
return `Execute the setup procedure "${spec.title}". Follow each step precisely.`;
|
|
@@ -348,7 +387,7 @@ function resolveModel(explicit) {
|
|
|
348
387
|
return envModel && envModel.length > 0 ? envModel : void 0;
|
|
349
388
|
}
|
|
350
389
|
async function invokeClaudeStreaming(options, onEvent) {
|
|
351
|
-
const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, maxTurns, env, model, onAbAction, onAbActionFailed, silenceBashLog = false } = options;
|
|
390
|
+
const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false } = options;
|
|
352
391
|
const resolvedModel = resolveModel(model);
|
|
353
392
|
let lastAbToolUseId = null;
|
|
354
393
|
const sdkOptions = {
|
|
@@ -358,6 +397,7 @@ async function invokeClaudeStreaming(options, onEvent) {
|
|
|
358
397
|
permissionMode: "bypassPermissions",
|
|
359
398
|
allowDangerouslySkipPermissions: true,
|
|
360
399
|
...resolvedModel ? { model: resolvedModel } : {},
|
|
400
|
+
...cwd ? { cwd } : {},
|
|
361
401
|
...env ? { env: {
|
|
362
402
|
...process.env,
|
|
363
403
|
...env
|
|
@@ -521,20 +561,93 @@ async function* replayMockMessages(path) {
|
|
|
521
561
|
}
|
|
522
562
|
}
|
|
523
563
|
//#endregion
|
|
564
|
+
//#region src/spec/parser.ts
|
|
565
|
+
function parseTestSpec(content) {
|
|
566
|
+
const { data, content: body } = matter(content);
|
|
567
|
+
const steps = parseSteps(body);
|
|
568
|
+
const prerequisites = parsePrerequisites(body);
|
|
569
|
+
return {
|
|
570
|
+
title: String(data["title"] ?? "Untitled"),
|
|
571
|
+
baseUrl: String(data["baseUrl"] ?? "http://localhost:3000"),
|
|
572
|
+
prerequisites: prerequisites || void 0,
|
|
573
|
+
setups: parseSetupRefs(data["setups"]),
|
|
574
|
+
relatedPaths: parseRelatedPaths(data["relatedPaths"]),
|
|
575
|
+
steps
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function parseRelatedPaths(raw) {
|
|
579
|
+
if (!Array.isArray(raw)) return void 0;
|
|
580
|
+
const paths = [];
|
|
581
|
+
for (const item of raw) if (typeof item === "string" && item.trim().length > 0) paths.push(item.trim());
|
|
582
|
+
return paths.length > 0 ? paths : void 0;
|
|
583
|
+
}
|
|
584
|
+
function parseSetupSpec(content) {
|
|
585
|
+
const { data, content: body } = matter(content);
|
|
586
|
+
const steps = parseSteps(body);
|
|
587
|
+
const placeholders = parsePlaceholders(data["placeholders"]);
|
|
588
|
+
return {
|
|
589
|
+
title: String(data["title"] ?? "Untitled"),
|
|
590
|
+
placeholders: Object.keys(placeholders).length > 0 ? placeholders : void 0,
|
|
591
|
+
steps
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function parsePlaceholders(raw) {
|
|
595
|
+
if (!raw || typeof raw !== "object") return {};
|
|
596
|
+
const result = {};
|
|
597
|
+
for (const [key, val] of Object.entries(raw)) if (val && typeof val === "object" && "dummy" in val) {
|
|
598
|
+
const v = val;
|
|
599
|
+
result[key] = {
|
|
600
|
+
dummy: String(v["dummy"]),
|
|
601
|
+
description: v["description"] ? String(v["description"]) : void 0
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
function parseSetupRefs(raw) {
|
|
607
|
+
if (!Array.isArray(raw)) return void 0;
|
|
608
|
+
const refs = [];
|
|
609
|
+
for (const item of raw) if (typeof item === "object" && item !== null && "name" in item) {
|
|
610
|
+
const i = item;
|
|
611
|
+
refs.push({
|
|
612
|
+
name: String(i["name"]),
|
|
613
|
+
params: i["params"] && typeof i["params"] === "object" ? Object.fromEntries(Object.entries(i["params"]).map(([k, v]) => [k, String(v)])) : void 0
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return refs.length > 0 ? refs : void 0;
|
|
617
|
+
}
|
|
618
|
+
function parsePrerequisites(body) {
|
|
619
|
+
const match = body.match(/##\s+Prerequisites\s+([\s\S]*?)(?=##|$)/);
|
|
620
|
+
if (!match || !match[1]) return null;
|
|
621
|
+
return match[1].trim();
|
|
622
|
+
}
|
|
623
|
+
function parseSteps(body) {
|
|
624
|
+
const stepBlocks = body.split(/###\s+Step\s+\d+:/);
|
|
625
|
+
const steps = [];
|
|
626
|
+
for (let i = 1; i < stepBlocks.length; i++) {
|
|
627
|
+
const block = stepBlocks[i];
|
|
628
|
+
if (!block) continue;
|
|
629
|
+
const titleMatch = block.match(/^(.+)/);
|
|
630
|
+
const instructionMatch = block.match(/\*\*Instruction\*\*:\s*(.+)/);
|
|
631
|
+
const expectedMatch = block.match(/\*\*Expected\*\*:\s*(.+)/);
|
|
632
|
+
if (!titleMatch || !instructionMatch || !expectedMatch) continue;
|
|
633
|
+
steps.push({
|
|
634
|
+
id: `step-${String(i).padStart(2, "0")}`,
|
|
635
|
+
title: titleMatch[1]?.trim() ?? "",
|
|
636
|
+
instruction: instructionMatch[1]?.trim() ?? "",
|
|
637
|
+
expected: expectedMatch[1]?.trim() ?? ""
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return steps;
|
|
641
|
+
}
|
|
642
|
+
//#endregion
|
|
524
643
|
//#region src/store/index.ts
|
|
525
644
|
const CCQA_DIR = ".ccqa";
|
|
526
645
|
function getCcqaDir(cwd = process.cwd()) {
|
|
527
646
|
return join(cwd, CCQA_DIR);
|
|
528
647
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
* - "tasks/create-and-complete"
|
|
533
|
-
* - "features/tasks/test-cases/create-and-complete"
|
|
534
|
-
* - ".ccqa/features/tasks/test-cases/create-and-complete"
|
|
535
|
-
* All forms resolve to { featureName: "tasks", specName: "create-and-complete" }.
|
|
536
|
-
* Trailing slashes are tolerated.
|
|
537
|
-
*/
|
|
648
|
+
function specKey(ref) {
|
|
649
|
+
return `${ref.featureName}/${ref.specName}`;
|
|
650
|
+
}
|
|
538
651
|
function parseSpecPath(specPath) {
|
|
539
652
|
const parts = specPath.replace(/^\.\/+/, "").replace(/\/+$/, "").split("/").filter((p) => p.length > 0);
|
|
540
653
|
if (parts[0] === ".ccqa") parts.shift();
|
|
@@ -573,6 +686,22 @@ async function saveSpecFile(featureName, specName, content, cwd) {
|
|
|
573
686
|
await writeFile(specPath, content.endsWith("\n") ? content : content + "\n", "utf-8");
|
|
574
687
|
return specPath;
|
|
575
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Replace (or insert) the `relatedPaths` key in the spec's YAML frontmatter.
|
|
691
|
+
* Preserves every other frontmatter key and the entire body. Returns the
|
|
692
|
+
* absolute path that was written, or null if the spec file does not exist.
|
|
693
|
+
*/
|
|
694
|
+
async function updateSpecRelatedPaths(featureName, specName, relatedPaths, cwd) {
|
|
695
|
+
const specPath = join(getSpecDir(featureName, specName, cwd), "test-spec.md");
|
|
696
|
+
const existing = await readFile(specPath, "utf-8").catch(() => null);
|
|
697
|
+
if (existing === null) return null;
|
|
698
|
+
const parsed = matter(existing);
|
|
699
|
+
const data = { ...parsed.data };
|
|
700
|
+
if (relatedPaths.length > 0) data["relatedPaths"] = relatedPaths;
|
|
701
|
+
else delete data["relatedPaths"];
|
|
702
|
+
await writeFile(specPath, matter.stringify(parsed.content, data), "utf-8");
|
|
703
|
+
return specPath;
|
|
704
|
+
}
|
|
576
705
|
async function saveRoute(featureName, specName, route, cwd) {
|
|
577
706
|
const specDir = getSpecDir(featureName, specName, cwd);
|
|
578
707
|
await mkdir(specDir, { recursive: true });
|
|
@@ -660,8 +789,8 @@ async function listSpecsForFeature(featureName, cwd) {
|
|
|
660
789
|
}
|
|
661
790
|
/**
|
|
662
791
|
* Lists every feature/spec dir under .ccqa/features/, regardless of whether
|
|
663
|
-
* the spec is fully drafted yet.
|
|
664
|
-
*
|
|
792
|
+
* the spec is fully drafted yet. Each spec file is read at most once: title
|
|
793
|
+
* and relatedPaths are both extracted from the same parse.
|
|
665
794
|
*/
|
|
666
795
|
async function listFeatureTree(cwd) {
|
|
667
796
|
const featuresDir = join(getCcqaDir(cwd), "features");
|
|
@@ -677,11 +806,21 @@ async function listFeatureTree(cwd) {
|
|
|
677
806
|
specName,
|
|
678
807
|
hasSpecFile: false
|
|
679
808
|
};
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
809
|
+
try {
|
|
810
|
+
const spec = parseTestSpec(content);
|
|
811
|
+
const entry = {
|
|
812
|
+
specName,
|
|
813
|
+
hasSpecFile: true
|
|
814
|
+
};
|
|
815
|
+
if (spec.title && spec.title !== "Untitled") entry.title = spec.title;
|
|
816
|
+
if (spec.relatedPaths) entry.relatedPaths = spec.relatedPaths;
|
|
817
|
+
return entry;
|
|
818
|
+
} catch {
|
|
819
|
+
return {
|
|
820
|
+
specName,
|
|
821
|
+
hasSpecFile: true
|
|
822
|
+
};
|
|
823
|
+
}
|
|
685
824
|
}))
|
|
686
825
|
};
|
|
687
826
|
}));
|
|
@@ -706,76 +845,28 @@ function routeToMarkdown(route) {
|
|
|
706
845
|
return lines.join("\n");
|
|
707
846
|
}
|
|
708
847
|
//#endregion
|
|
709
|
-
//#region src/
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
steps
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
function parsePlaceholders(raw) {
|
|
733
|
-
if (!raw || typeof raw !== "object") return {};
|
|
734
|
-
const result = {};
|
|
735
|
-
for (const [key, val] of Object.entries(raw)) if (val && typeof val === "object" && "dummy" in val) {
|
|
736
|
-
const v = val;
|
|
737
|
-
result[key] = {
|
|
738
|
-
dummy: String(v["dummy"]),
|
|
739
|
-
description: v["description"] ? String(v["description"]) : void 0
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
return result;
|
|
743
|
-
}
|
|
744
|
-
function parseSetupRefs(raw) {
|
|
745
|
-
if (!Array.isArray(raw)) return void 0;
|
|
746
|
-
const refs = [];
|
|
747
|
-
for (const item of raw) if (typeof item === "object" && item !== null && "name" in item) {
|
|
748
|
-
const i = item;
|
|
749
|
-
refs.push({
|
|
750
|
-
name: String(i["name"]),
|
|
751
|
-
params: i["params"] && typeof i["params"] === "object" ? Object.fromEntries(Object.entries(i["params"]).map(([k, v]) => [k, String(v)])) : void 0
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
return refs.length > 0 ? refs : void 0;
|
|
755
|
-
}
|
|
756
|
-
function parsePrerequisites(body) {
|
|
757
|
-
const match = body.match(/##\s+Prerequisites\s+([\s\S]*?)(?=##|$)/);
|
|
758
|
-
if (!match || !match[1]) return null;
|
|
759
|
-
return match[1].trim();
|
|
760
|
-
}
|
|
761
|
-
function parseSteps(body) {
|
|
762
|
-
const stepBlocks = body.split(/###\s+Step\s+\d+:/);
|
|
763
|
-
const steps = [];
|
|
764
|
-
for (let i = 1; i < stepBlocks.length; i++) {
|
|
765
|
-
const block = stepBlocks[i];
|
|
766
|
-
if (!block) continue;
|
|
767
|
-
const titleMatch = block.match(/^(.+)/);
|
|
768
|
-
const instructionMatch = block.match(/\*\*Instruction\*\*:\s*(.+)/);
|
|
769
|
-
const expectedMatch = block.match(/\*\*Expected\*\*:\s*(.+)/);
|
|
770
|
-
if (!titleMatch || !instructionMatch || !expectedMatch) continue;
|
|
771
|
-
steps.push({
|
|
772
|
-
id: `step-${String(i).padStart(2, "0")}`,
|
|
773
|
-
title: titleMatch[1]?.trim() ?? "",
|
|
774
|
-
instruction: instructionMatch[1]?.trim() ?? "",
|
|
775
|
-
expected: expectedMatch[1]?.trim() ?? ""
|
|
776
|
-
});
|
|
848
|
+
//#region src/drift/parse-related-paths.ts
|
|
849
|
+
/**
|
|
850
|
+
* Pull a `RELATED_PATHS_BEGIN ... RELATED_PATHS_END` block out of the trace
|
|
851
|
+
* agent's combined text output. Lines inside the block become entries; blank
|
|
852
|
+
* lines, bullet markers, and code fences are tolerated. Returns null when the
|
|
853
|
+
* agent did not emit a block at all so the caller can warn instead of silently
|
|
854
|
+
* clearing the spec's existing relatedPaths.
|
|
855
|
+
*/
|
|
856
|
+
function parseRelatedPathsBlock(text) {
|
|
857
|
+
const match = text.match(/RELATED_PATHS_BEGIN\s*\n?([\s\S]*?)\n?RELATED_PATHS_END/);
|
|
858
|
+
if (!match || match[1] === void 0) return null;
|
|
859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
860
|
+
const out = [];
|
|
861
|
+
for (const raw of match[1].split("\n")) {
|
|
862
|
+
const line = raw.replace(/^```.*$/, "").trim();
|
|
863
|
+
if (!line) continue;
|
|
864
|
+
const cleaned = line.replace(/^[-*]\s+/, "").trim();
|
|
865
|
+
if (!cleaned || seen.has(cleaned)) continue;
|
|
866
|
+
seen.add(cleaned);
|
|
867
|
+
out.push(cleaned);
|
|
777
868
|
}
|
|
778
|
-
return
|
|
869
|
+
return out;
|
|
779
870
|
}
|
|
780
871
|
//#endregion
|
|
781
872
|
//#region src/runtime/bundled-config.ts
|
|
@@ -1061,6 +1152,7 @@ async function runTrace(featureName, specName, model) {
|
|
|
1061
1152
|
const routeSteps = [];
|
|
1062
1153
|
let overallStatus = "passed";
|
|
1063
1154
|
const traceActions = [];
|
|
1155
|
+
let relatedPathsBuffer = null;
|
|
1064
1156
|
const { isError } = await invokeClaudeStreaming({
|
|
1065
1157
|
prompt,
|
|
1066
1158
|
systemPrompt,
|
|
@@ -1087,6 +1179,11 @@ async function runTrace(featureName, specName, model) {
|
|
|
1087
1179
|
for (const block of msg.message.content ?? []) {
|
|
1088
1180
|
if (block.type !== "text" || !block.text) continue;
|
|
1089
1181
|
const text = block.text;
|
|
1182
|
+
if (relatedPathsBuffer !== null) relatedPathsBuffer += text + "\n";
|
|
1183
|
+
else {
|
|
1184
|
+
const idx = text.indexOf("RELATED_PATHS_BEGIN");
|
|
1185
|
+
if (idx !== -1) relatedPathsBuffer = text.slice(idx) + "\n";
|
|
1186
|
+
}
|
|
1090
1187
|
const statusLine = parseStatusLine(text);
|
|
1091
1188
|
if (statusLine) step(statusLine.type, statusLine.stepId, statusLine.detail);
|
|
1092
1189
|
for (const line of text.split("\n")) {
|
|
@@ -1117,6 +1214,11 @@ async function runTrace(featureName, specName, model) {
|
|
|
1117
1214
|
meta("saved", actionsPath);
|
|
1118
1215
|
meta("actions", traceActions.length);
|
|
1119
1216
|
meta("status", overallStatus.toUpperCase());
|
|
1217
|
+
const relatedPaths = relatedPathsBuffer !== null ? parseRelatedPathsBlock(relatedPathsBuffer) : null;
|
|
1218
|
+
if (relatedPaths !== null) {
|
|
1219
|
+
const written = await updateSpecRelatedPaths(featureName, specName, relatedPaths);
|
|
1220
|
+
if (written) meta("relatedPaths", `${relatedPaths.length} path(s) written to ${written}`);
|
|
1221
|
+
} else warn("trace did not emit a RELATED_PATHS block; drift --changed cannot scope this spec");
|
|
1120
1222
|
hint(`run 'ccqa generate ${featureName}/${specName}' to generate a test script`);
|
|
1121
1223
|
}
|
|
1122
1224
|
/**
|
|
@@ -2820,6 +2922,20 @@ async function cleanupActions(actions, model) {
|
|
|
2820
2922
|
return actions;
|
|
2821
2923
|
}
|
|
2822
2924
|
//#endregion
|
|
2925
|
+
//#region src/claude/extract-json.ts
|
|
2926
|
+
/**
|
|
2927
|
+
* Pulls a JSON object out of a Claude completion. Accepts either a fenced
|
|
2928
|
+
* ```json block or a bare `{...}` payload that constitutes the whole reply.
|
|
2929
|
+
* Returns null when neither shape is present.
|
|
2930
|
+
*/
|
|
2931
|
+
function extractJsonBlock(text) {
|
|
2932
|
+
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
|
2933
|
+
if (fenced && fenced[1]) return fenced[1].trim();
|
|
2934
|
+
const trimmed = text.trim();
|
|
2935
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed;
|
|
2936
|
+
return null;
|
|
2937
|
+
}
|
|
2938
|
+
//#endregion
|
|
2823
2939
|
//#region src/prompts/draft.ts
|
|
2824
2940
|
function buildNamingSystemPrompt() {
|
|
2825
2941
|
return `You name a new ccqa test case based on the user's intent and the existing feature tree.
|
|
@@ -2877,6 +2993,7 @@ Frontmatter fields:
|
|
|
2877
2993
|
- baseUrl: string (required, e.g. http://localhost:3000)
|
|
2878
2994
|
- prerequisites: string (optional, free text)
|
|
2879
2995
|
- setups: array of { name: string, params?: Record<string,string> } (optional)
|
|
2996
|
+
- relatedPaths: array of string (optional) — glob patterns identifying source files this spec depends on. Used by \`ccqa drift --changed\` in CI to skip drift checks for unrelated changes.
|
|
2880
2997
|
|
|
2881
2998
|
Body must contain a \`## Steps\` section followed by step blocks:
|
|
2882
2999
|
|
|
@@ -2900,7 +3017,8 @@ Body must contain a \`## Steps\` section followed by step blocks:
|
|
|
2900
3017
|
|
|
2901
3018
|
1. Read the codebase under cwd to find concrete strings: routes, button labels, aria-labels, page titles, placeholders. Use those exact strings in **Expected**.
|
|
2902
3019
|
2. If the spec references setups, Read \`.ccqa/setups/<name>/setup-spec.md\` and verify each \`params\` key matches the setup's \`placeholders\`.
|
|
2903
|
-
3.
|
|
3020
|
+
3. Populate \`relatedPaths\` in the frontmatter with **provisional** glob patterns pointing at the source files this spec touches: the route/page file for each URL the spec visits, plus the component files (or their parent feature directory) that render the aria-labels, placeholders, or visible texts the spec asserts on. Prefer directory globs (e.g. \`src/features/tasks/**\`) when several files in one area are involved. Be conservative — include a path if you're unsure rather than omit it. \`ccqa trace\` will refine this list later from real browser observations.
|
|
3021
|
+
4. Validate the (current or proposed) spec on four axes — emit one issue per finding:
|
|
2904
3022
|
- **assertable**: each Expected can be verified against a string/URL/state that exists in code.
|
|
2905
3023
|
- **setups**: referenced setup exists; params keys match placeholders.
|
|
2906
3024
|
- **granularity**: not too coarse (multiple actions per step) nor too fine (snapshot-only steps); order is logical.
|
|
@@ -2986,6 +3104,7 @@ z.object({
|
|
|
2986
3104
|
baseUrl: z.string(),
|
|
2987
3105
|
prerequisites: z.string().optional(),
|
|
2988
3106
|
setups: z.array(SetupRefSchema).optional(),
|
|
3107
|
+
relatedPaths: z.array(z.string()).optional(),
|
|
2989
3108
|
steps: z.array(TestStepSchema)
|
|
2990
3109
|
});
|
|
2991
3110
|
const PlaceholderDefSchema = z.object({
|
|
@@ -3041,7 +3160,7 @@ const DraftNamingSchema = z.object({
|
|
|
3041
3160
|
});
|
|
3042
3161
|
//#endregion
|
|
3043
3162
|
//#region src/cli/draft.ts
|
|
3044
|
-
const CATEGORY_LABEL = {
|
|
3163
|
+
const CATEGORY_LABEL$1 = {
|
|
3045
3164
|
assertable: "Assertability",
|
|
3046
3165
|
setups: "Setup references",
|
|
3047
3166
|
granularity: "Step granularity",
|
|
@@ -3230,24 +3349,24 @@ function printReviewBlock(issues) {
|
|
|
3230
3349
|
}
|
|
3231
3350
|
if (errors.length) {
|
|
3232
3351
|
process.stdout.write(` ERRORS (${errors.length})\n`);
|
|
3233
|
-
for (const issue of errors) writeFinding(issue);
|
|
3352
|
+
for (const issue of errors) writeFinding$1(issue);
|
|
3234
3353
|
process.stdout.write("\n");
|
|
3235
3354
|
}
|
|
3236
3355
|
if (warnings.length) {
|
|
3237
3356
|
process.stdout.write(` WARNINGS (${warnings.length})\n`);
|
|
3238
|
-
for (const issue of warnings) writeFinding(issue);
|
|
3357
|
+
for (const issue of warnings) writeFinding$1(issue);
|
|
3239
3358
|
process.stdout.write("\n");
|
|
3240
3359
|
}
|
|
3241
3360
|
if (passed.length) {
|
|
3242
|
-
const names = passed.map((i) => CATEGORY_LABEL[i.category]).join(", ");
|
|
3361
|
+
const names = passed.map((i) => CATEGORY_LABEL$1[i.category]).join(", ");
|
|
3243
3362
|
process.stdout.write(` PASSED (${passed.length})\n ${names}\n`);
|
|
3244
3363
|
}
|
|
3245
3364
|
process.stdout.write(`\n${RULE}\n\n`);
|
|
3246
3365
|
return errors.length > 0;
|
|
3247
3366
|
}
|
|
3248
|
-
function writeFinding(issue) {
|
|
3367
|
+
function writeFinding$1(issue) {
|
|
3249
3368
|
const stepPart = issue.stepId ? ` ${issue.stepId}` : "";
|
|
3250
|
-
process.stdout.write(` ${CATEGORY_LABEL[issue.category]}${stepPart}\n`);
|
|
3369
|
+
process.stdout.write(` ${CATEGORY_LABEL$1[issue.category]}${stepPart}\n`);
|
|
3251
3370
|
process.stdout.write(` ${issue.message}\n`);
|
|
3252
3371
|
if (issue.detail) process.stdout.write(` └ ${issue.detail.replace(/\n/g, "\n ")}\n`);
|
|
3253
3372
|
}
|
|
@@ -3367,13 +3486,6 @@ function ensureUnique(tree, featureName, specName) {
|
|
|
3367
3486
|
specName: `${specName}-${Date.now()}`
|
|
3368
3487
|
};
|
|
3369
3488
|
}
|
|
3370
|
-
function extractJsonBlock(text) {
|
|
3371
|
-
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
|
3372
|
-
if (fenced && fenced[1]) return fenced[1].trim();
|
|
3373
|
-
const trimmed = text.trim();
|
|
3374
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed;
|
|
3375
|
-
return null;
|
|
3376
|
-
}
|
|
3377
3489
|
function printUnifiedDiff(before, after) {
|
|
3378
3490
|
const lines = computeLineDiff(before.split("\n"), after.split("\n"));
|
|
3379
3491
|
for (const line of lines) process.stdout.write(line + "\n");
|
|
@@ -3421,6 +3533,645 @@ function truncate(s, n) {
|
|
|
3421
3533
|
return s.slice(s.length - n);
|
|
3422
3534
|
}
|
|
3423
3535
|
//#endregion
|
|
3536
|
+
//#region src/prompts/drift.ts
|
|
3537
|
+
function buildDriftSystemPrompt() {
|
|
3538
|
+
return `${buildDraftSystemPrompt()}
|
|
3539
|
+
|
|
3540
|
+
## Drift mode
|
|
3541
|
+
|
|
3542
|
+
You are running non-interactively in CI. The user will not see or apply the patch — only the \`issues\` array.
|
|
3543
|
+
|
|
3544
|
+
- Always set \`patch\` to "" in your response.
|
|
3545
|
+
- Focus issue messages on what is **out of sync** between the spec and the current codebase: missing aria-labels, renamed routes, removed buttons, placeholders that no longer exist, setup references that point to non-existent files.
|
|
3546
|
+
- Do NOT raise issues about stylistic preferences in the spec wording.
|
|
3547
|
+
- Treat \`category: unimplemented\` as the primary signal for drift: anything the spec asserts that you cannot find in code is a drift finding.
|
|
3548
|
+
|
|
3549
|
+
## Drift severity policy (STRICT)
|
|
3550
|
+
|
|
3551
|
+
The CLI exits non-zero when any issue has \`severity: "ERROR"\` (default) or — with \`--severity warn\` — when any \`WARN\` is present. Pick severity by **whether a deterministic replay of this spec would fail today**, not by how confident you are in your own analysis.
|
|
3552
|
+
|
|
3553
|
+
Use **ERROR** when the spec would break on replay:
|
|
3554
|
+
- A selector the spec relies on (\`aria-label\`, \`placeholder\`, \`data-testid\`, button text) **does not exist anywhere in the source**.
|
|
3555
|
+
- A URL / route the spec navigates to is no longer defined.
|
|
3556
|
+
- An **Expected** asserts a string or visible text that is no longer rendered by the relevant component.
|
|
3557
|
+
- A \`setups[].name\` does not resolve to \`.ccqa/setups/<name>/setup-spec.md\`, or a \`params\` key is not declared in that setup's \`placeholders\`.
|
|
3558
|
+
- The spec references a feature/page that has been removed from the codebase.
|
|
3559
|
+
|
|
3560
|
+
Use **WARN** when the spec is still likely to work, but quality could improve:
|
|
3561
|
+
- The Expected is vague ("a message appears") when a precise string exists in code.
|
|
3562
|
+
- A step bundles multiple actions, or a needed intermediate verification step is missing.
|
|
3563
|
+
- Stable signals exist that the spec could leverage but currently doesn't.
|
|
3564
|
+
- You are unsure whether a referenced string exists (give the user the benefit of the doubt; do not hard-fail CI on uncertainty).
|
|
3565
|
+
|
|
3566
|
+
Use **OK** for axes you actively verified and found no issue.
|
|
3567
|
+
|
|
3568
|
+
If you cannot decide between ERROR and WARN, choose WARN. Reserve ERROR for findings you can back up with a specific file path or grep result that proves the drift.
|
|
3569
|
+
`;
|
|
3570
|
+
}
|
|
3571
|
+
function buildDriftUserPrompt(existing) {
|
|
3572
|
+
return buildDraftPrompt({
|
|
3573
|
+
mode: "refine",
|
|
3574
|
+
existing,
|
|
3575
|
+
userInput: ""
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
//#endregion
|
|
3579
|
+
//#region src/drift/affected.ts
|
|
3580
|
+
const execFileP = promisify(execFile);
|
|
3581
|
+
/**
|
|
3582
|
+
* Resolve the base ref to diff against for `ccqa drift --changed`.
|
|
3583
|
+
* Precedence: explicit override > GITHUB_BASE_REF > origin/main.
|
|
3584
|
+
*/
|
|
3585
|
+
function resolveBaseRef(explicit) {
|
|
3586
|
+
if (explicit && explicit.length > 0) return explicit;
|
|
3587
|
+
const ghBase = process.env["GITHUB_BASE_REF"];
|
|
3588
|
+
if (ghBase && ghBase.length > 0) return ghBase.startsWith("origin/") ? ghBase : `origin/${ghBase}`;
|
|
3589
|
+
return "origin/main";
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Run `git diff --name-status base...HEAD` from `cwd` and return one entry per
|
|
3593
|
+
* changed file. Renames are reported under their NEW path with status
|
|
3594
|
+
* "renamed" — the OLD path is dropped because the spec mapping is against the
|
|
3595
|
+
* post-rename layout.
|
|
3596
|
+
*
|
|
3597
|
+
* Paths are re-rooted to be relative to `cwd`, not the git repo root. In a
|
|
3598
|
+
* monorepo where `cwd` is a sub-package (e.g. `js/apps/knowledge-webapp`),
|
|
3599
|
+
* git emits paths relative to the repo root, but specs declare relatedPaths
|
|
3600
|
+
* relative to their own package. Changes outside `cwd` are dropped so an
|
|
3601
|
+
* unrelated PR can never accidentally scope a sub-package's specs in.
|
|
3602
|
+
*/
|
|
3603
|
+
async function getChangedFiles(base, cwd) {
|
|
3604
|
+
const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
|
|
3605
|
+
"diff",
|
|
3606
|
+
"--name-status",
|
|
3607
|
+
"-M",
|
|
3608
|
+
`${base}...HEAD`
|
|
3609
|
+
], {
|
|
3610
|
+
cwd,
|
|
3611
|
+
maxBuffer: 32 * 1024 * 1024
|
|
3612
|
+
})]);
|
|
3613
|
+
return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd);
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Convert paths in `entries` from git-repo-root relative to `cwd` relative,
|
|
3617
|
+
* dropping anything outside `cwd`. Exported for unit tests.
|
|
3618
|
+
*/
|
|
3619
|
+
function rerootChangedFiles(entries, repoRoot, cwd) {
|
|
3620
|
+
const prefix = relative(repoRoot, cwd);
|
|
3621
|
+
if (!prefix) return entries;
|
|
3622
|
+
const out = [];
|
|
3623
|
+
for (const e of entries) {
|
|
3624
|
+
const rel = relative(prefix, e.path);
|
|
3625
|
+
if (rel.startsWith("..") || rel === "") continue;
|
|
3626
|
+
out.push({
|
|
3627
|
+
...e,
|
|
3628
|
+
path: rel
|
|
3629
|
+
});
|
|
3630
|
+
}
|
|
3631
|
+
return out;
|
|
3632
|
+
}
|
|
3633
|
+
function parseGitDiffOutput(stdout) {
|
|
3634
|
+
const out = [];
|
|
3635
|
+
for (const line of stdout.split("\n")) {
|
|
3636
|
+
if (!line.trim()) continue;
|
|
3637
|
+
const parts = line.split(" ");
|
|
3638
|
+
const code = parts[0];
|
|
3639
|
+
if (!code) continue;
|
|
3640
|
+
if (code.startsWith("R")) {
|
|
3641
|
+
const newPath = parts[2];
|
|
3642
|
+
if (newPath) out.push({
|
|
3643
|
+
path: newPath,
|
|
3644
|
+
status: "renamed"
|
|
3645
|
+
});
|
|
3646
|
+
continue;
|
|
3647
|
+
}
|
|
3648
|
+
if (code.startsWith("C")) {
|
|
3649
|
+
const newPath = parts[2];
|
|
3650
|
+
if (newPath) out.push({
|
|
3651
|
+
path: newPath,
|
|
3652
|
+
status: "added"
|
|
3653
|
+
});
|
|
3654
|
+
continue;
|
|
3655
|
+
}
|
|
3656
|
+
const path = parts[1];
|
|
3657
|
+
if (!path) continue;
|
|
3658
|
+
switch (code[0]) {
|
|
3659
|
+
case "A":
|
|
3660
|
+
out.push({
|
|
3661
|
+
path,
|
|
3662
|
+
status: "added"
|
|
3663
|
+
});
|
|
3664
|
+
break;
|
|
3665
|
+
case "M":
|
|
3666
|
+
case "T":
|
|
3667
|
+
out.push({
|
|
3668
|
+
path,
|
|
3669
|
+
status: "modified"
|
|
3670
|
+
});
|
|
3671
|
+
break;
|
|
3672
|
+
case "D":
|
|
3673
|
+
out.push({
|
|
3674
|
+
path,
|
|
3675
|
+
status: "deleted"
|
|
3676
|
+
});
|
|
3677
|
+
break;
|
|
3678
|
+
default: out.push({
|
|
3679
|
+
path,
|
|
3680
|
+
status: "modified"
|
|
3681
|
+
});
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
return out;
|
|
3685
|
+
}
|
|
3686
|
+
function stripLeadingDotSlash(s) {
|
|
3687
|
+
return s.startsWith("./") ? s.slice(2) : s;
|
|
3688
|
+
}
|
|
3689
|
+
const REGEX_CACHE = /* @__PURE__ */ new Map();
|
|
3690
|
+
/** Compiles `pattern` to a RegExp, memoized so repeated `--changed` matches don't re-build. */
|
|
3691
|
+
function compileGlob(pattern) {
|
|
3692
|
+
const cached = REGEX_CACHE.get(pattern);
|
|
3693
|
+
if (cached) return cached;
|
|
3694
|
+
const compiled = globToRegExp(stripLeadingDotSlash(pattern));
|
|
3695
|
+
REGEX_CACHE.set(pattern, compiled);
|
|
3696
|
+
return compiled;
|
|
3697
|
+
}
|
|
3698
|
+
function globToRegExp(pattern) {
|
|
3699
|
+
let re = "^";
|
|
3700
|
+
let i = 0;
|
|
3701
|
+
while (i < pattern.length) {
|
|
3702
|
+
const ch = pattern[i];
|
|
3703
|
+
if (ch === "?") {
|
|
3704
|
+
re += "[^/]";
|
|
3705
|
+
i++;
|
|
3706
|
+
continue;
|
|
3707
|
+
}
|
|
3708
|
+
if (ch !== "*") {
|
|
3709
|
+
re += /[.+^${}()|[\]\\]/.test(ch) ? "\\" + ch : ch;
|
|
3710
|
+
i++;
|
|
3711
|
+
continue;
|
|
3712
|
+
}
|
|
3713
|
+
if (pattern[i + 1] !== "*") {
|
|
3714
|
+
re += "[^/]*";
|
|
3715
|
+
i++;
|
|
3716
|
+
continue;
|
|
3717
|
+
}
|
|
3718
|
+
const hasLeadingSlash = re.endsWith("/");
|
|
3719
|
+
const hasTrailingSlash = pattern[i + 2] === "/";
|
|
3720
|
+
if (hasLeadingSlash) re = re.slice(0, -1);
|
|
3721
|
+
if (hasLeadingSlash || hasTrailingSlash) re += "(?:/?.*)?";
|
|
3722
|
+
else re += ".*";
|
|
3723
|
+
i += hasTrailingSlash ? 3 : 2;
|
|
3724
|
+
}
|
|
3725
|
+
return new RegExp(re + "$");
|
|
3726
|
+
}
|
|
3727
|
+
/**
|
|
3728
|
+
* Returns true if `changedPath` is covered by any of `relatedPaths`. An empty
|
|
3729
|
+
* `relatedPaths` returns false — callers handle the "unscoped spec" case
|
|
3730
|
+
* separately (treat the spec as always-affected) before calling this.
|
|
3731
|
+
*/
|
|
3732
|
+
function isPathAffectedBy(changedPath, relatedPaths) {
|
|
3733
|
+
const stripped = stripLeadingDotSlash(changedPath);
|
|
3734
|
+
for (const pattern of relatedPaths) if (compileGlob(pattern).test(stripped)) return true;
|
|
3735
|
+
return false;
|
|
3736
|
+
}
|
|
3737
|
+
//#endregion
|
|
3738
|
+
//#region src/drift/route-new-files.ts
|
|
3739
|
+
/**
|
|
3740
|
+
* Lightweight Claude call: given a list of new files in the PR and the existing
|
|
3741
|
+
* specs (with their relatedPaths globs as a hint), return the spec keys (in
|
|
3742
|
+
* "<feature>/<spec>" form) that the new files plausibly affect.
|
|
3743
|
+
*
|
|
3744
|
+
* Conservative by design — false positives are safer than false negatives,
|
|
3745
|
+
* because a missed spec turns into undetected drift in CI. When the router
|
|
3746
|
+
* call itself fails, we log a warning rather than fail-close: the surrounding
|
|
3747
|
+
* glob match is the primary signal; the router only adds coverage for new
|
|
3748
|
+
* paths no glob captures.
|
|
3749
|
+
*/
|
|
3750
|
+
async function routeNewFilesToSpecs(input) {
|
|
3751
|
+
const { newFiles, specs, cwd, model } = input;
|
|
3752
|
+
const empty = /* @__PURE__ */ new Set();
|
|
3753
|
+
if (newFiles.length === 0 || specs.length === 0) return empty;
|
|
3754
|
+
const { result, isError } = await invokeClaudeStreaming({
|
|
3755
|
+
prompt: buildRouterPrompt(await Promise.all(newFiles.map(async (path) => ({
|
|
3756
|
+
path,
|
|
3757
|
+
head: await readHead(join(cwd, path))
|
|
3758
|
+
}))), specs),
|
|
3759
|
+
systemPrompt: buildRouterSystemPrompt(),
|
|
3760
|
+
allowedTools: [
|
|
3761
|
+
"Read",
|
|
3762
|
+
"Grep",
|
|
3763
|
+
"Glob"
|
|
3764
|
+
],
|
|
3765
|
+
silenceBashLog: true,
|
|
3766
|
+
cwd,
|
|
3767
|
+
...model ? { model } : {}
|
|
3768
|
+
}, (_msg) => {});
|
|
3769
|
+
if (isError) {
|
|
3770
|
+
warn("new-file router: Claude returned an error; skipping router signal");
|
|
3771
|
+
return empty;
|
|
3772
|
+
}
|
|
3773
|
+
const json = extractJsonBlock(result);
|
|
3774
|
+
if (!json) {
|
|
3775
|
+
warn("new-file router: no JSON block in response; skipping router signal");
|
|
3776
|
+
return empty;
|
|
3777
|
+
}
|
|
3778
|
+
let parsed;
|
|
3779
|
+
try {
|
|
3780
|
+
parsed = JSON.parse(json);
|
|
3781
|
+
} catch (e) {
|
|
3782
|
+
warn(`new-file router: failed to parse JSON (${e.message}); skipping router signal`);
|
|
3783
|
+
return empty;
|
|
3784
|
+
}
|
|
3785
|
+
const out = /* @__PURE__ */ new Set();
|
|
3786
|
+
const validKeys = new Set(specs.map((s) => `${s.featureName}/${s.specName}`));
|
|
3787
|
+
if (typeof parsed === "object" && parsed !== null && "affectedSpecs" in parsed) {
|
|
3788
|
+
const arr = parsed.affectedSpecs;
|
|
3789
|
+
if (Array.isArray(arr)) {
|
|
3790
|
+
for (const item of arr) if (typeof item === "string" && validKeys.has(item)) out.add(item);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
return out;
|
|
3794
|
+
}
|
|
3795
|
+
async function readHead(absPath) {
|
|
3796
|
+
const content = await readFile(absPath, "utf-8").catch(() => "");
|
|
3797
|
+
if (!content) return "";
|
|
3798
|
+
return content.split("\n").slice(0, 40).join("\n");
|
|
3799
|
+
}
|
|
3800
|
+
function buildRouterSystemPrompt() {
|
|
3801
|
+
return `You triage which ccqa test specs are potentially affected by NEW source files added in a pull request.
|
|
3802
|
+
|
|
3803
|
+
You will receive:
|
|
3804
|
+
- A list of new files (path + first ~40 lines of each)
|
|
3805
|
+
- A list of existing specs with their declared relatedPaths globs
|
|
3806
|
+
|
|
3807
|
+
Your job: return the spec keys (in "<feature>/<spec>" form) whose behaviour might depend on any of the new files.
|
|
3808
|
+
|
|
3809
|
+
## Rules
|
|
3810
|
+
|
|
3811
|
+
- 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.
|
|
3812
|
+
- 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.
|
|
3813
|
+
- 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/**\`).
|
|
3814
|
+
- Files like tests, generated code, build artifacts, vendor dirs typically do not affect any spec. Skip them.
|
|
3815
|
+
|
|
3816
|
+
## Output (STRICT)
|
|
3817
|
+
|
|
3818
|
+
Output ONE fenced \`\`\`json block, nothing else:
|
|
3819
|
+
|
|
3820
|
+
\`\`\`json
|
|
3821
|
+
{
|
|
3822
|
+
"affectedSpecs": ["feature/spec", "feature/spec"]
|
|
3823
|
+
}
|
|
3824
|
+
\`\`\`
|
|
3825
|
+
|
|
3826
|
+
Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
|
|
3827
|
+
`;
|
|
3828
|
+
}
|
|
3829
|
+
function buildRouterPrompt(previews, specs) {
|
|
3830
|
+
return `## New files
|
|
3831
|
+
|
|
3832
|
+
${previews.map((p) => {
|
|
3833
|
+
const headBlock = p.head ? `\n\`\`\`\n${p.head}\n\`\`\`` : "\n(empty or unreadable)";
|
|
3834
|
+
return `### ${p.path}${headBlock}`;
|
|
3835
|
+
}).join("\n\n")}
|
|
3836
|
+
|
|
3837
|
+
## Existing specs
|
|
3838
|
+
|
|
3839
|
+
${specs.map((s) => {
|
|
3840
|
+
const title = s.title ? ` — ${s.title}` : "";
|
|
3841
|
+
const paths = s.relatedPaths.length === 0 ? " (no relatedPaths declared)" : s.relatedPaths.map((p) => ` - ${p}`).join("\n");
|
|
3842
|
+
return `- ${s.featureName}/${s.specName}${title}\n${paths}`;
|
|
3843
|
+
}).join("\n")}
|
|
3844
|
+
|
|
3845
|
+
## Task
|
|
3846
|
+
|
|
3847
|
+
Return the spec keys that might be affected by any of the new files. Conservative inclusion is preferred over missing real drift.
|
|
3848
|
+
`;
|
|
3849
|
+
}
|
|
3850
|
+
//#endregion
|
|
3851
|
+
//#region src/cli/drift.ts
|
|
3852
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
3853
|
+
const driftCommand = new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Check whether each test-spec.md is still in sync with the current codebase (CI-friendly, no patches applied).").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.").action(async (specPath, opts) => {
|
|
3854
|
+
const format = parseFormat(opts.format);
|
|
3855
|
+
const threshold = parseSeverity(opts.severity);
|
|
3856
|
+
const concurrency = parseConcurrency(opts.concurrency);
|
|
3857
|
+
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
3858
|
+
await ensureCcqaDir(cwd);
|
|
3859
|
+
if (opts.changed && specPath) {
|
|
3860
|
+
error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
|
|
3861
|
+
process.exit(2);
|
|
3862
|
+
}
|
|
3863
|
+
let targets = await collectTargets(specPath, cwd);
|
|
3864
|
+
if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
|
|
3865
|
+
if (format === "text") {
|
|
3866
|
+
header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
|
|
3867
|
+
if (opts.cwd) meta("cwd", cwd);
|
|
3868
|
+
}
|
|
3869
|
+
if (opts.changed) {
|
|
3870
|
+
const total = targets.length;
|
|
3871
|
+
targets = await filterByChanged({
|
|
3872
|
+
targets,
|
|
3873
|
+
cwd,
|
|
3874
|
+
baseOverride: opts.base,
|
|
3875
|
+
format,
|
|
3876
|
+
model: opts.model
|
|
3877
|
+
});
|
|
3878
|
+
if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
|
|
3879
|
+
if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
|
|
3880
|
+
}
|
|
3881
|
+
const results = await runChecks(targets, concurrency, opts.model, cwd, format);
|
|
3882
|
+
emitReport(results, format, cwd);
|
|
3883
|
+
process.exit(determineExitCode(results, threshold));
|
|
3884
|
+
});
|
|
3885
|
+
function exitWithNoSpecs(format, message) {
|
|
3886
|
+
if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
|
|
3887
|
+
else if (format === "text") info(message);
|
|
3888
|
+
process.exit(0);
|
|
3889
|
+
}
|
|
3890
|
+
async function filterByChanged(input) {
|
|
3891
|
+
const { targets, cwd, baseOverride, format, model } = input;
|
|
3892
|
+
const base = resolveBaseRef(baseOverride);
|
|
3893
|
+
let changed;
|
|
3894
|
+
try {
|
|
3895
|
+
changed = await getChangedFiles(base, cwd);
|
|
3896
|
+
} catch (e) {
|
|
3897
|
+
error(`failed to run 'git diff' against ${base}: ${e.message}`);
|
|
3898
|
+
process.exit(2);
|
|
3899
|
+
}
|
|
3900
|
+
if (format === "text") {
|
|
3901
|
+
meta("changed-base", base);
|
|
3902
|
+
meta("changed-files", changed.length);
|
|
3903
|
+
}
|
|
3904
|
+
if (changed.length === 0) return [];
|
|
3905
|
+
const newFiles = changed.filter((f) => f.status === "added");
|
|
3906
|
+
const existingChanges = changed.filter((f) => f.status !== "added");
|
|
3907
|
+
const affected = /* @__PURE__ */ new Set();
|
|
3908
|
+
for (const t of targets) {
|
|
3909
|
+
if (!t.relatedPaths) {
|
|
3910
|
+
affected.add(specKey(t));
|
|
3911
|
+
continue;
|
|
3912
|
+
}
|
|
3913
|
+
if (existingChanges.some((f) => isPathAffectedBy(f.path, t.relatedPaths)) || newFiles.some((f) => isPathAffectedBy(f.path, t.relatedPaths))) affected.add(specKey(t));
|
|
3914
|
+
}
|
|
3915
|
+
if (newFiles.length > 0) {
|
|
3916
|
+
if (format === "text") info(`routing ${newFiles.length} new file(s) to specs via Claude...`);
|
|
3917
|
+
const routed = await routeNewFilesToSpecs({
|
|
3918
|
+
newFiles: newFiles.map((f) => f.path),
|
|
3919
|
+
specs: targets.filter((t) => t.relatedPaths).map((t) => ({
|
|
3920
|
+
featureName: t.featureName,
|
|
3921
|
+
specName: t.specName,
|
|
3922
|
+
title: t.title,
|
|
3923
|
+
relatedPaths: t.relatedPaths
|
|
3924
|
+
})),
|
|
3925
|
+
cwd,
|
|
3926
|
+
model
|
|
3927
|
+
});
|
|
3928
|
+
for (const key of routed) affected.add(key);
|
|
3929
|
+
}
|
|
3930
|
+
return targets.filter((t) => affected.has(specKey(t)));
|
|
3931
|
+
}
|
|
3932
|
+
async function collectTargets(specPath, cwd) {
|
|
3933
|
+
if (specPath) {
|
|
3934
|
+
const { featureName, specName } = parseSpecPath(specPath);
|
|
3935
|
+
if (await tryReadSpecFile(featureName, specName, cwd) === null) {
|
|
3936
|
+
error(`spec not found: ${featureName}/${specName} (under ${cwd})`);
|
|
3937
|
+
process.exit(1);
|
|
3938
|
+
}
|
|
3939
|
+
return [{
|
|
3940
|
+
featureName,
|
|
3941
|
+
specName
|
|
3942
|
+
}];
|
|
3943
|
+
}
|
|
3944
|
+
const tree = await listFeatureTree(cwd);
|
|
3945
|
+
const out = [];
|
|
3946
|
+
for (const feature of tree) for (const spec of feature.specs) {
|
|
3947
|
+
if (!spec.hasSpecFile) continue;
|
|
3948
|
+
const t = {
|
|
3949
|
+
featureName: feature.featureName,
|
|
3950
|
+
specName: spec.specName
|
|
3951
|
+
};
|
|
3952
|
+
if (spec.relatedPaths) t.relatedPaths = spec.relatedPaths;
|
|
3953
|
+
if (spec.title) t.title = spec.title;
|
|
3954
|
+
out.push(t);
|
|
3955
|
+
}
|
|
3956
|
+
return out;
|
|
3957
|
+
}
|
|
3958
|
+
async function runChecks(targets, concurrency, model, cwd, format) {
|
|
3959
|
+
const results = new Array(targets.length);
|
|
3960
|
+
let cursor = 0;
|
|
3961
|
+
const worker = async () => {
|
|
3962
|
+
while (true) {
|
|
3963
|
+
const idx = cursor++;
|
|
3964
|
+
if (idx >= targets.length) return;
|
|
3965
|
+
const target = targets[idx];
|
|
3966
|
+
results[idx] = await checkSpec(target, model, cwd, format);
|
|
3967
|
+
}
|
|
3968
|
+
};
|
|
3969
|
+
const pool = Array.from({ length: Math.min(concurrency, targets.length) }, () => worker());
|
|
3970
|
+
await Promise.all(pool);
|
|
3971
|
+
return results;
|
|
3972
|
+
}
|
|
3973
|
+
async function checkSpec(target, model, cwd, format) {
|
|
3974
|
+
const { featureName, specName } = target;
|
|
3975
|
+
const existing = await tryReadSpecFile(featureName, specName, cwd);
|
|
3976
|
+
if (existing === null) return {
|
|
3977
|
+
target,
|
|
3978
|
+
ok: false,
|
|
3979
|
+
issues: [],
|
|
3980
|
+
error: `spec file disappeared after enumeration: ${featureName}/${specName}`
|
|
3981
|
+
};
|
|
3982
|
+
if (format === "text") info(`checking ${featureName}/${specName}`);
|
|
3983
|
+
const { result, isError } = await invokeClaudeStreaming({
|
|
3984
|
+
prompt: buildDriftUserPrompt(existing),
|
|
3985
|
+
systemPrompt: buildDriftSystemPrompt(),
|
|
3986
|
+
allowedTools: [
|
|
3987
|
+
"Read",
|
|
3988
|
+
"Grep",
|
|
3989
|
+
"Glob"
|
|
3990
|
+
],
|
|
3991
|
+
silenceBashLog: true,
|
|
3992
|
+
cwd,
|
|
3993
|
+
...model ? { model } : {}
|
|
3994
|
+
}, (_msg) => {});
|
|
3995
|
+
if (isError) return {
|
|
3996
|
+
target,
|
|
3997
|
+
ok: false,
|
|
3998
|
+
issues: [],
|
|
3999
|
+
error: "Claude returned an error result"
|
|
4000
|
+
};
|
|
4001
|
+
const json = extractJsonBlock(result);
|
|
4002
|
+
if (!json) return {
|
|
4003
|
+
target,
|
|
4004
|
+
ok: false,
|
|
4005
|
+
issues: [],
|
|
4006
|
+
error: "Claude did not return a json block"
|
|
4007
|
+
};
|
|
4008
|
+
let report;
|
|
4009
|
+
try {
|
|
4010
|
+
report = DraftReportSchema.parse(JSON.parse(json));
|
|
4011
|
+
} catch (e) {
|
|
4012
|
+
return {
|
|
4013
|
+
target,
|
|
4014
|
+
ok: false,
|
|
4015
|
+
issues: [],
|
|
4016
|
+
error: `failed to parse drift report: ${e.message}`
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
return {
|
|
4020
|
+
target,
|
|
4021
|
+
ok: true,
|
|
4022
|
+
issues: report.issues
|
|
4023
|
+
};
|
|
4024
|
+
}
|
|
4025
|
+
function emitReport(results, format, cwd) {
|
|
4026
|
+
if (format === "json") {
|
|
4027
|
+
emitJson(results);
|
|
4028
|
+
return;
|
|
4029
|
+
}
|
|
4030
|
+
if (format === "github") {
|
|
4031
|
+
emitGithub(results, cwd);
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
emitText(results);
|
|
4035
|
+
}
|
|
4036
|
+
const CATEGORY_LABEL = {
|
|
4037
|
+
assertable: "Assertability",
|
|
4038
|
+
setups: "Setup references",
|
|
4039
|
+
granularity: "Step granularity",
|
|
4040
|
+
unimplemented: "Unimplemented checks"
|
|
4041
|
+
};
|
|
4042
|
+
const HEAVY_RULE = "═".repeat(72);
|
|
4043
|
+
function emitText(results) {
|
|
4044
|
+
for (const r of results) {
|
|
4045
|
+
blank();
|
|
4046
|
+
const heading = `══ ${r.target.featureName}/${r.target.specName} `;
|
|
4047
|
+
const tail = "═".repeat(Math.max(3, 72 - heading.length));
|
|
4048
|
+
process.stdout.write(`${heading}${tail}\n`);
|
|
4049
|
+
if (r.error) {
|
|
4050
|
+
process.stdout.write(` ERROR ${r.error}\n`);
|
|
4051
|
+
continue;
|
|
4052
|
+
}
|
|
4053
|
+
const errors = r.issues.filter((i) => i.severity === "ERROR");
|
|
4054
|
+
const warnings = r.issues.filter((i) => i.severity === "WARN");
|
|
4055
|
+
const passed = r.issues.filter((i) => i.severity === "OK");
|
|
4056
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
4057
|
+
const label = passed.length === 1 ? "check" : "checks";
|
|
4058
|
+
const detail = passed.length > 0 ? `all ${passed.length} ${label} passed` : "no issues";
|
|
4059
|
+
process.stdout.write(` ✓ ${detail}\n`);
|
|
4060
|
+
continue;
|
|
4061
|
+
}
|
|
4062
|
+
for (const issue of errors) writeFinding("ERROR", issue);
|
|
4063
|
+
for (const issue of warnings) writeFinding("WARN", issue);
|
|
4064
|
+
if (passed.length > 0) {
|
|
4065
|
+
const names = passed.map((i) => CATEGORY_LABEL[i.category]).join(", ");
|
|
4066
|
+
process.stdout.write(`\n ✓ passed (${passed.length}): ${names}\n`);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
blank();
|
|
4070
|
+
process.stdout.write(`${HEAVY_RULE}\n`);
|
|
4071
|
+
const totals = summarize(results);
|
|
4072
|
+
meta("specs", `${results.length} (${totals.errored} errored)`);
|
|
4073
|
+
meta("findings", `${totals.error} error, ${totals.warn} warn, ${totals.ok} ok`);
|
|
4074
|
+
}
|
|
4075
|
+
function writeFinding(level, issue) {
|
|
4076
|
+
const stepPart = issue.stepId ? ` ${issue.stepId}` : "";
|
|
4077
|
+
process.stdout.write(`\n ${level} ${CATEGORY_LABEL[issue.category]}${stepPart}\n`);
|
|
4078
|
+
process.stdout.write(` ${issue.message}\n`);
|
|
4079
|
+
if (issue.detail) process.stdout.write(` └ ${issue.detail.replace(/\n/g, "\n ")}\n`);
|
|
4080
|
+
}
|
|
4081
|
+
function emitJson(results) {
|
|
4082
|
+
const payload = { specs: results.map((r) => ({
|
|
4083
|
+
feature: r.target.featureName,
|
|
4084
|
+
spec: r.target.specName,
|
|
4085
|
+
ok: r.ok,
|
|
4086
|
+
...r.error ? { error: r.error } : {},
|
|
4087
|
+
issues: r.issues.map((i) => ({
|
|
4088
|
+
severity: i.severity,
|
|
4089
|
+
category: i.category,
|
|
4090
|
+
stepId: i.stepId,
|
|
4091
|
+
message: i.message,
|
|
4092
|
+
...i.detail ? { detail: i.detail } : {}
|
|
4093
|
+
}))
|
|
4094
|
+
})) };
|
|
4095
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
4096
|
+
}
|
|
4097
|
+
function emitGithub(results, cwd) {
|
|
4098
|
+
const repoRoot = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
|
|
4099
|
+
for (const r of results) {
|
|
4100
|
+
const file = githubRelPath(cwd, repoRoot, r.target.featureName, r.target.specName);
|
|
4101
|
+
if (r.error) {
|
|
4102
|
+
process.stdout.write(`::error file=${file}::${escapeGhMessage(r.error)}\n`);
|
|
4103
|
+
continue;
|
|
4104
|
+
}
|
|
4105
|
+
for (const issue of r.issues) {
|
|
4106
|
+
if (issue.severity === "OK") continue;
|
|
4107
|
+
const level = issue.severity === "ERROR" ? "error" : "warning";
|
|
4108
|
+
const title = `${r.target.featureName}/${r.target.specName} — ${issue.category}${issue.stepId ? ` (${issue.stepId})` : ""}`;
|
|
4109
|
+
const body = issue.detail ? `${issue.message}\n${issue.detail}` : issue.message;
|
|
4110
|
+
process.stdout.write(`::${level} file=${file},title=${escapeGhProp(title)}::${escapeGhMessage(body)}\n`);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
function githubRelPath(cwd, repoRoot, featureName, specName) {
|
|
4115
|
+
const abs = resolve(cwd, ".ccqa", "features", featureName, "test-cases", specName, "test-spec.md");
|
|
4116
|
+
const rel = relative(repoRoot, abs);
|
|
4117
|
+
return rel.startsWith("..") ? abs : rel;
|
|
4118
|
+
}
|
|
4119
|
+
function escapeGhMessage(s) {
|
|
4120
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
4121
|
+
}
|
|
4122
|
+
function escapeGhProp(s) {
|
|
4123
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/,/g, "%2C").replace(/:/g, "%3A");
|
|
4124
|
+
}
|
|
4125
|
+
function summarize(results) {
|
|
4126
|
+
let error = 0;
|
|
4127
|
+
let warn = 0;
|
|
4128
|
+
let ok = 0;
|
|
4129
|
+
let errored = 0;
|
|
4130
|
+
for (const r of results) {
|
|
4131
|
+
if (r.error) errored++;
|
|
4132
|
+
for (const issue of r.issues) if (issue.severity === "ERROR") error++;
|
|
4133
|
+
else if (issue.severity === "WARN") warn++;
|
|
4134
|
+
else ok++;
|
|
4135
|
+
}
|
|
4136
|
+
return {
|
|
4137
|
+
error,
|
|
4138
|
+
warn,
|
|
4139
|
+
ok,
|
|
4140
|
+
errored
|
|
4141
|
+
};
|
|
4142
|
+
}
|
|
4143
|
+
function determineExitCode(results, threshold) {
|
|
4144
|
+
for (const r of results) {
|
|
4145
|
+
if (r.error) return 1;
|
|
4146
|
+
for (const issue of r.issues) {
|
|
4147
|
+
if (issue.severity === "ERROR") return 1;
|
|
4148
|
+
if (threshold === "warn" && issue.severity === "WARN") return 1;
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
return 0;
|
|
4152
|
+
}
|
|
4153
|
+
function parseFormat(raw) {
|
|
4154
|
+
const v = raw ?? "text";
|
|
4155
|
+
if (v === "text" || v === "json" || v === "github") return v;
|
|
4156
|
+
error(`invalid --format: ${v} (expected text|json|github)`);
|
|
4157
|
+
process.exit(2);
|
|
4158
|
+
}
|
|
4159
|
+
function parseSeverity(raw) {
|
|
4160
|
+
const v = raw ?? "error";
|
|
4161
|
+
if (v === "warn" || v === "error") return v;
|
|
4162
|
+
error(`invalid --severity: ${v} (expected warn|error)`);
|
|
4163
|
+
process.exit(2);
|
|
4164
|
+
}
|
|
4165
|
+
function parseConcurrency(raw) {
|
|
4166
|
+
if (raw === void 0) return DEFAULT_CONCURRENCY;
|
|
4167
|
+
const n = Number.parseInt(raw, 10);
|
|
4168
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
4169
|
+
error(`invalid --concurrency: ${raw} (expected positive integer)`);
|
|
4170
|
+
process.exit(2);
|
|
4171
|
+
}
|
|
4172
|
+
return n;
|
|
4173
|
+
}
|
|
4174
|
+
//#endregion
|
|
3424
4175
|
//#region src/cli/index.ts
|
|
3425
4176
|
const packageJsonPath = resolvePackageJson();
|
|
3426
4177
|
const { version } = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
@@ -3437,6 +4188,7 @@ function resolvePackageJson() {
|
|
|
3437
4188
|
const program = new Command();
|
|
3438
4189
|
program.name("ccqa").description("E2E test CLI using Claude Code + agent-browser").version(version);
|
|
3439
4190
|
program.addCommand(draftCommand);
|
|
4191
|
+
program.addCommand(driftCommand);
|
|
3440
4192
|
program.addCommand(traceCommand);
|
|
3441
4193
|
program.addCommand(generateCommand);
|
|
3442
4194
|
program.addCommand(runCommand);
|