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/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 buildTraceSystemPrompt({
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
- * Accepts both the canonical 2-segment alias and the on-disk 4-segment path
531
- * (which is what shell tab-completion produces):
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. Used by `ccqa draft` to suggest non-colliding
664
- * feature/spec names that fit the existing structure.
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
- return {
681
- specName,
682
- hasSpecFile: true,
683
- title: content.match(/^title:\s*"?([^"\n]+)"?/m)?.[1]?.trim()
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/spec/parser.ts
710
- function parseTestSpec(content) {
711
- const { data, content: body } = matter(content);
712
- const steps = parseSteps(body);
713
- const prerequisites = parsePrerequisites(body);
714
- return {
715
- title: String(data["title"] ?? "Untitled"),
716
- baseUrl: String(data["baseUrl"] ?? "http://localhost:3000"),
717
- prerequisites: prerequisites || void 0,
718
- setups: parseSetupRefs(data["setups"]),
719
- steps
720
- };
721
- }
722
- function parseSetupSpec(content) {
723
- const { data, content: body } = matter(content);
724
- const steps = parseSteps(body);
725
- const placeholders = parsePlaceholders(data["placeholders"]);
726
- return {
727
- title: String(data["title"] ?? "Untitled"),
728
- placeholders: Object.keys(placeholders).length > 0 ? placeholders : void 0,
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 steps;
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. Validate the (current or proposed) spec on four axes emit one issue per finding:
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);