ccqa 1.56.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -28084,7 +28084,7 @@ async function checkSpec(target, opts) {
28084
28084
  locators,
28085
28085
  systemPrompt,
28086
28086
  userPrompt
28087
- }).then((path) => meta("inputs", path), (err) => warn(`${name}: could not write the audit inputs (${err.message})`));
28087
+ }).catch((err) => warn(`${name}: could not write the audit inputs (${err.message})`));
28088
28088
  const MAX_ATTEMPTS = 2;
28089
28089
  let lastError = "";
28090
28090
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -28155,12 +28155,12 @@ async function checkSpec(target, opts) {
28155
28155
  //#endregion
28156
28156
  //#region src/drift/format.ts
28157
28157
  /**
28158
- * Render drift results as a string. The CLI commands and the `run` failure
28159
- * hook are the only callers; both want the formatted output returned so
28160
- * they can prefix / interleave / pipe it as needed.
28158
+ * Render drift results for a terminal. `json` is not one of these: it prints
28159
+ * the same payload the report file holds, which is assembled asynchronously
28160
+ * and so is built once by the caller rather than rendered from results here
28161
+ * (`buildAuditReport`).
28161
28162
  */
28162
28163
  function renderDrift(results, format, cwd) {
28163
- if (format === "json") return renderJson(results);
28164
28164
  if (format === "github") return renderGithub(results, cwd);
28165
28165
  return renderText$1(results);
28166
28166
  }
@@ -28195,16 +28195,6 @@ function renderText$1(results) {
28195
28195
  out.push("");
28196
28196
  return out.join("\n");
28197
28197
  }
28198
- function renderJson(results) {
28199
- const payload = { specs: results.map((r) => ({
28200
- feature: r.target.featureName,
28201
- spec: r.target.specName,
28202
- ok: r.ok,
28203
- ...r.error ? { error: r.error } : {},
28204
- drift: r.drift
28205
- })) };
28206
- return `${JSON.stringify(payload, null, 2)}\n`;
28207
- }
28208
28198
  function renderGithub(results, cwd) {
28209
28199
  const repoRoot = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
28210
28200
  const lines = [];
@@ -28335,41 +28325,49 @@ function driftResultsToReport(results, meta) {
28335
28325
  };
28336
28326
  }
28337
28327
  //#endregion
28338
- //#region src/drift/brief.ts
28328
+ //#region src/drift/audit-report.ts
28339
28329
  /**
28340
- * Write one JSON file per finding, named by the case id below `dir`. Returns
28341
- * the paths written, in the order the results came in.
28330
+ * What `ccqa audit` leaves behind for a machine.
28331
+ *
28332
+ * The audit has three audiences: the person reading the terminal, the hub's
28333
+ * ledger, and whatever repairs the test. This is the third, and it is one
28334
+ * payload — written to `<report-dir>/audit.json` by every completed sweep and
28335
+ * printed verbatim by `--report-format json` (ADR-0036).
28342
28336
  */
28343
- async function writeAuditBriefs(input) {
28344
- const findings = input.results.filter((r) => r.drift !== null);
28345
- if (findings.length === 0) return [];
28346
- const ctx = input.context ?? await loadSpecArtifactsContext(input.cwd);
28347
- const dirAbs = resolve(input.cwd, input.dir);
28348
- return Promise.all(findings.map(async (result) => {
28349
- const brief = await buildBrief(result, input.cwd, ctx);
28350
- const path = join(dirAbs, `${brief.case}.json`);
28351
- await mkdir(dirname(path), { recursive: true });
28352
- await writeFile(path, `${JSON.stringify(brief, null, 2)}\n`, "utf8");
28353
- return path;
28354
- }));
28337
+ /** The file the report is written as, inside the report directory. */
28338
+ const AUDIT_REPORT_FILE = "audit.json";
28339
+ /**
28340
+ * The payload, for both the file and `--report-format json`. Each row's reads
28341
+ * are independent, so they resolve together rather than one case at a time.
28342
+ */
28343
+ async function buildAuditReport(results, cwd, ctx) {
28344
+ return { specs: await Promise.all(results.map((r) => buildRow(r, cwd, ctx))) };
28355
28345
  }
28356
- async function buildBrief(result, cwd, ctx) {
28346
+ /** Write the report as `<dirAbs>/audit.json`, creating the directory. Returns its path. */
28347
+ async function writeAuditReport(report, dirAbs) {
28348
+ await mkdir(dirAbs, { recursive: true });
28349
+ const path = join(dirAbs, AUDIT_REPORT_FILE);
28350
+ await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
28351
+ return path;
28352
+ }
28353
+ async function buildRow(result, cwd, ctx) {
28357
28354
  const drift = result.drift;
28358
- const id = caseIdOf(result.target);
28355
+ const row = {
28356
+ feature: result.target.featureName,
28357
+ spec: result.target.specName,
28358
+ case: caseIdOf(result.target),
28359
+ ok: result.ok,
28360
+ ...result.error ? { error: result.error } : {},
28361
+ drift
28362
+ };
28363
+ if (drift === null) return row;
28359
28364
  const test = result.live ? null : await caseTestPath(result.target, ctx);
28360
28365
  const rewrite = drift.label === "TEST_DRIFT" ? (result.renames ?? []).filter((r) => r.inDocument).map(({ from, to }) => ({
28361
28366
  from,
28362
28367
  to
28363
28368
  })) : [];
28364
28369
  return {
28365
- case: id,
28366
- kind: drift.label,
28367
- surface: drift.surface,
28368
- confidence: drift.confidence,
28369
- headline: drift.headline,
28370
- recommendation: drift.recommendation,
28371
- reasoning: drift.reasoning,
28372
- evidence: drift.evidence,
28370
+ ...row,
28373
28371
  test,
28374
28372
  document: documentOf(result, cwd),
28375
28373
  repair: await buildRepair(result, test, rewrite, cwd, ctx)
@@ -28637,7 +28635,7 @@ function selectSpecsNeedingAudit(targets, report, stillDrifted = /* @__PURE__ */
28637
28635
  //#endregion
28638
28636
  //#region src/cli/audit.ts
28639
28637
  const DEFAULT_CONCURRENCY = 3;
28640
- const auditCommand = addProfileOption(addLanguageOption(new Command("audit").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Read each spec against the code it describes and report where the two have drifted. Static: no browser is run, so this is the cheap check to put in front of `ccqa run`.").optionsGroup("Which specs to audit:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` decides the diff against <ref> reaches (e.g. origin/main), by intersecting it with each spec's measured coverage from the hub. In pull_request CI, pass $GITHUB_BASE_REF. Specs without a measurement are audited rather than skipped.").option("--only-hub-audit-needed", "Only specs the hub says a deploy has landed on since the audit last read them. A spec that was never audited is always included, one the hub cannot answer for is audited rather than skipped, and one whose drift entry is still open is always re-audited — a merged fix changes only the spec tree, which no deploy answer covers. No git diff involved. Requires a hub connection and --hub-profile.").optionsGroup("How to run it:").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.").optionsGroup("What to do with the results:").option("--report-format <fmt>", "Output format: text | json | github", "text").option("--report-to-hub", "Push the result to a ccqa hub as a run (kind: drift), which is what updates the drift ledger. A spec it finds drifted answers `needsRepair` to `ccqa run --only-hub-rerun-needed`, and is not run until a person repairs it.").option("--exit-on <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--brief <dir>", "Also write one JSON file per finding under <dir>, named by case id: the verdict, its citations, which repair the case needs (regenerate, re-record, rewrite its document, or a person), and any renamed strings to apply to that document first. What reads them is outside ccqa.").option("--dump-inputs <dir>", "Also write what each case's audit was given — its document, every file it was handed and the import that reached it, the source roots, and the prompt — as one markdown file per case under <dir>. For telling 'this never reached the audit' apart from 'it did, and nothing was reported'.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption))).action(withUsageErrors(async (specPath, opts) => {
28638
+ const auditCommand = addProfileOption(addLanguageOption(new Command("audit").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Read each spec against the code it describes and report where the two have drifted. Static: no browser is run, so this is the cheap check to put in front of `ccqa run`.").optionsGroup("Which specs to audit:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` decides the diff against <ref> reaches (e.g. origin/main), by intersecting it with each spec's measured coverage from the hub. In pull_request CI, pass $GITHUB_BASE_REF. Specs without a measurement are audited rather than skipped.").option("--only-hub-audit-needed", "Only specs the hub says a deploy has landed on since the audit last read them. A spec that was never audited is always included, one the hub cannot answer for is audited rather than skipped, and one whose drift entry is still open is always re-audited — a merged fix changes only the spec tree, which no deploy answer covers. No git diff involved. Requires a hub connection and --hub-profile.").optionsGroup("How to run it:").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.").optionsGroup("What to do with the results:").option("--report-dir <dir>", `Directory for the structured audit results (audit.json), which are always written. Default: ${DEFAULT_REPORT_DIR}/.`).option("--report-format <fmt>", "Terminal output format: text | json | github. 'json' prints the same payload audit.json holds, and nothing else.", "text").option("--report-to-hub", "Push the result to a ccqa hub as a run (kind: drift), which is what updates the drift ledger. A spec it finds drifted answers `needsRepair` to `ccqa run --only-hub-rerun-needed`, and is not run until a person repairs it.").option("--exit-on <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--dump-inputs <dir>", "Also write what each case's audit was given — its document, every file it was handed and the import that reached it, the source roots, and the prompt — as one markdown file per case under <dir>. For telling 'this never reached the audit' apart from 'it did, and nothing was reported'.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption))).action(withUsageErrors(async (specPath, opts) => {
28641
28639
  await withCostReporting("audit", () => runAudit(specPath, opts));
28642
28640
  }));
28643
28641
  async function runAudit(specPath, opts) {
@@ -28645,6 +28643,11 @@ async function runAudit(specPath, opts) {
28645
28643
  const threshold = parseSeverity(opts.exitOn);
28646
28644
  const concurrency = parseConcurrency(opts.concurrency);
28647
28645
  const cwd = resolveCwd(opts.cwd);
28646
+ const reportDir = resolve(cwd, opts.reportDir ?? "ccqa-report");
28647
+ await rm(join(reportDir, AUDIT_REPORT_FILE), {
28648
+ force: true,
28649
+ recursive: true
28650
+ }).catch(() => {});
28648
28651
  await ensureCcqaDir(cwd);
28649
28652
  if (opts.onlyAffectedBy && specPath) {
28650
28653
  error("--only-affected-by and an explicit spec id cannot be combined; it only applies to a full sweep");
@@ -28678,11 +28681,18 @@ async function runAudit(specPath, opts) {
28678
28681
  const intent = artifactsContext.reader.target;
28679
28682
  const sourceRoots = await resolveSourceRoots(cwd, config.sourceRoots);
28680
28683
  let targets = await collectTargets(specPath, cwd, artifactsContext.reader);
28681
- if (targets.length === 0) exitWithNoSpecs(format, "noSpecsFound", `no test cases found under ${intent ? intent.module : ".ccqa/features/"}`);
28684
+ if (targets.length === 0) {
28685
+ const where = intent ? intent.module : ".ccqa/features/";
28686
+ await exitWithNoSpecs({
28687
+ format,
28688
+ reportDir
28689
+ }, "noSpecsFound", `no test cases found under ${where}`);
28690
+ }
28682
28691
  if (format === "text") {
28683
28692
  header("audit", specPath ?? `${targets.length} case${targets.length > 1 ? "s" : ""}`);
28684
28693
  if (opts.cwd) meta("cwd", cwd);
28685
28694
  for (const root of sourceRoots) meta("source", root.abs);
28695
+ if (opts.dumpInputs) meta("inputs", resolve(cwd, opts.dumpInputs));
28686
28696
  }
28687
28697
  if (opts.onlyHubAuditNeeded) {
28688
28698
  const total = targets.length;
@@ -28697,12 +28707,18 @@ async function runAudit(specPath, opts) {
28697
28707
  meta("hub", selection.summary);
28698
28708
  meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
28699
28709
  }
28700
- if (targets.length === 0) exitWithNoSpecs(format, "allCurrent", "every spec has been audited since the last deploy that reached it");
28710
+ if (targets.length === 0) await exitWithNoSpecs({
28711
+ format,
28712
+ reportDir
28713
+ }, "allCurrent", "every spec has been audited since the last deploy that reached it");
28701
28714
  holder = randomUUID();
28702
28715
  const claimed = await claimSpecs(hub, hubProject, opts.hubProfile, targets, holder);
28703
28716
  if (claimed.length < targets.length && format === "text") meta("held-elsewhere", `${targets.length - claimed.length} spec(s) another job is auditing`);
28704
28717
  targets = claimed;
28705
- if (targets.length === 0) exitWithNoSpecs(format, "allHeld", "every spec that needs auditing is already being audited by another job");
28718
+ if (targets.length === 0) await exitWithNoSpecs({
28719
+ format,
28720
+ reportDir
28721
+ }, "allHeld", "every spec that needs auditing is already being audited by another job");
28706
28722
  }
28707
28723
  let baseRef = null;
28708
28724
  if (opts.onlyAffectedBy) {
@@ -28716,7 +28732,10 @@ async function runAudit(specPath, opts) {
28716
28732
  targets = selection.specs;
28717
28733
  baseRef = selection.base.ref;
28718
28734
  if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
28719
- if (targets.length === 0) exitWithNoSpecs(format, "noDiffIntersection", "no specs intersect the changed file set; nothing to check");
28735
+ if (targets.length === 0) await exitWithNoSpecs({
28736
+ format,
28737
+ reportDir
28738
+ }, "noDiffIntersection", "no specs intersect the changed file set; nothing to check");
28720
28739
  }
28721
28740
  const blocks = await loadAvailableBlocks(cwd);
28722
28741
  let pushConn = null;
@@ -28757,7 +28776,10 @@ async function runAudit(specPath, opts) {
28757
28776
  } finally {
28758
28777
  if (holder) await releaseSpecs(hub, hubProject, opts.hubProfile, holder);
28759
28778
  }
28760
- process.stdout.write(renderDrift(results, format, cwd));
28779
+ const report = await buildAuditReport(results, cwd, artifactsContext);
28780
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
28781
+ else process.stdout.write(renderDrift(results, format, cwd));
28782
+ const written = await tryWriteReport(report, reportDir, format);
28761
28783
  if (push) await sealDriftPush(push, {
28762
28784
  results,
28763
28785
  threshold,
@@ -28766,18 +28788,22 @@ async function runAudit(specPath, opts) {
28766
28788
  baseRef,
28767
28789
  promptCtx
28768
28790
  });
28769
- if (opts.brief) try {
28770
- const written = await writeAuditBriefs({
28771
- results,
28772
- cwd,
28773
- dir: opts.brief,
28774
- context: artifactsContext
28775
- });
28776
- if (format === "text") meta("briefs", `${written.length} written to ${opts.brief}`);
28791
+ process.exit(written ? determineExitCode(results, threshold) : 2);
28792
+ }
28793
+ /**
28794
+ * Write the report, saying whether it landed. A sweep whose results never
28795
+ * landed has to fail: the next reader of `audit.json` would otherwise act on
28796
+ * the previous invocation's findings with nothing saying so.
28797
+ */
28798
+ async function tryWriteReport(report, dirAbs, format) {
28799
+ try {
28800
+ const path = await writeAuditReport(report, dirAbs);
28801
+ if (format === "text") meta("report", path);
28802
+ return true;
28777
28803
  } catch (e) {
28778
- error(`could not write briefs to ${opts.brief}: ${errMessage(e)}`);
28804
+ error(`could not write the audit report to ${dirAbs}: ${errMessage(e)}`);
28805
+ return false;
28779
28806
  }
28780
- process.exit(determineExitCode(results, threshold));
28781
28807
  }
28782
28808
  /** Longer than the audit's own timeout, so a sweep cannot outlive its claim. */
28783
28809
  const AUDIT_LOCK_TTL_SECONDS = 5400;
@@ -28903,14 +28929,21 @@ async function resolveAuditPromptContext(opts, cwd, resolveCtx = resolveAuditHub
28903
28929
  function asHubReadError(err) {
28904
28930
  throw new RunUsageError(`could not read from the hub: ${errMessage(err)}`);
28905
28931
  }
28906
- function exitWithNoSpecs(format, reason, message) {
28907
- if (format === "json") process.stdout.write(`${JSON.stringify({
28932
+ /**
28933
+ * Nothing to audit — still a result, and still written. A reader that found
28934
+ * yesterday's `audit.json` where this sweep left none would act on findings
28935
+ * nothing had re-checked.
28936
+ */
28937
+ async function exitWithNoSpecs(ctx, reason, message) {
28938
+ const { format, reportDir } = ctx;
28939
+ const report = {
28908
28940
  specs: [],
28909
28941
  skipped: reason
28910
- }, null, 2)}\n`);
28942
+ };
28943
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
28911
28944
  else if (format === "text") info(message);
28912
28945
  else if (format === "github" && reason === "noSpecsFound") process.stdout.write(`::warning::${message}\n`);
28913
- process.exit(0);
28946
+ process.exit(await tryWriteReport(report, reportDir, format) ? 0 : 2);
28914
28947
  }
28915
28948
  /**
28916
28949
  * The cases this sweep audits.
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
- record: "record";
39
38
  run: "run";
40
39
  drift: "drift";
40
+ record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -652,8 +652,8 @@ declare const ReportSpecResultSchema: z.ZodObject<{
652
652
  title: z.ZodNullable<z.ZodString>;
653
653
  target: z.ZodOptional<z.ZodString>;
654
654
  mode: z.ZodOptional<z.ZodEnum<{
655
- deterministic: "deterministic";
656
655
  live: "live";
656
+ deterministic: "deterministic";
657
657
  }>>;
658
658
  status: z.ZodEnum<{
659
659
  passed: "passed";
@@ -831,9 +831,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
831
831
  declare const RunReportDataSchema: z.ZodObject<{
832
832
  schemaVersion: z.ZodLiteral<1>;
833
833
  kind: z.ZodDefault<z.ZodEnum<{
834
- record: "record";
835
834
  run: "run";
836
835
  drift: "drift";
836
+ record: "record";
837
837
  }>>;
838
838
  createdAt: z.ZodString;
839
839
  runId: z.ZodNullable<z.ZodString>;
@@ -874,8 +874,8 @@ declare const RunReportDataSchema: z.ZodObject<{
874
874
  title: z.ZodNullable<z.ZodString>;
875
875
  target: z.ZodOptional<z.ZodString>;
876
876
  mode: z.ZodOptional<z.ZodEnum<{
877
- deterministic: "deterministic";
878
877
  live: "live";
878
+ deterministic: "deterministic";
879
879
  }>>;
880
880
  status: z.ZodEnum<{
881
881
  passed: "passed";
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
- record: "record";
39
38
  run: "run";
40
39
  drift: "drift";
40
+ record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -652,8 +652,8 @@ declare const ReportSpecResultSchema: z.ZodObject<{
652
652
  title: z.ZodNullable<z.ZodString>;
653
653
  target: z.ZodOptional<z.ZodString>;
654
654
  mode: z.ZodOptional<z.ZodEnum<{
655
- deterministic: "deterministic";
656
655
  live: "live";
656
+ deterministic: "deterministic";
657
657
  }>>;
658
658
  status: z.ZodEnum<{
659
659
  passed: "passed";
@@ -831,9 +831,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
831
831
  declare const RunReportDataSchema: z.ZodObject<{
832
832
  schemaVersion: z.ZodLiteral<1>;
833
833
  kind: z.ZodDefault<z.ZodEnum<{
834
- record: "record";
835
834
  run: "run";
836
835
  drift: "drift";
836
+ record: "record";
837
837
  }>>;
838
838
  createdAt: z.ZodString;
839
839
  runId: z.ZodNullable<z.ZodString>;
@@ -874,8 +874,8 @@ declare const RunReportDataSchema: z.ZodObject<{
874
874
  title: z.ZodNullable<z.ZodString>;
875
875
  target: z.ZodOptional<z.ZodString>;
876
876
  mode: z.ZodOptional<z.ZodEnum<{
877
- deterministic: "deterministic";
878
877
  live: "live";
878
+ deterministic: "deterministic";
879
879
  }>>;
880
880
  status: z.ZodEnum<{
881
881
  passed: "passed";
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {