ccqa 1.60.1 → 1.60.2

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
@@ -1650,10 +1650,10 @@ async function saveRecording(ref, actions, cleanup = [], stepDigests) {
1650
1650
  *
1651
1651
  * One directory, not one per attempt: a project whose tests belong to its own
1652
1652
  * runner never calls `ccqa run`, so this stands in for the run report the
1653
- * evidence table reads — and what stands in for it is the attempt that passed,
1654
- * which there is only ever one of. `ccqa generate` clears it before each
1655
- * attempt and removes it when none passed, because screenshots of a failing
1656
- * run are a debugging aid and this table presents them as the case working.
1653
+ * evidence table reads — and what stands in for it is the attempt that passed
1654
+ * last, the version generation keeps. `ccqa generate` replaces it only on a
1655
+ * pass and removes it when none passed, because screenshots of a failing run
1656
+ * are a debugging aid and this table presents them as the case working.
1657
1657
  */
1658
1658
  function caseRunDir(ref) {
1659
1659
  return join(ref.dir, EVIDENCE_DIR);
@@ -6535,7 +6535,7 @@ function parseStepComment(line) {
6535
6535
  * every step then reads as deciding nothing and every rewrite is rejected.
6536
6536
  */
6537
6537
  function stepLines(source) {
6538
- return source.replace(STEP_CALL, (call) => call.replace(/\s*\n\s*/g, " ")).split("\n");
6538
+ return source.replace(STEP_CALL, (call) => "\n".repeat(call.split("\n").length - 1) + call.replace(/\s*\n\s*/g, " ")).split("\n");
6539
6539
  }
6540
6540
  /**
6541
6541
  * The step id a `test.step` title names — the block form only.
@@ -8338,6 +8338,31 @@ function exportedNames(source) {
8338
8338
  }
8339
8339
  //#endregion
8340
8340
  //#region src/targets/emitted-review.ts
8341
+ /**
8342
+ * A mechanical read of the code a generation just wrote, before it is called
8343
+ * done.
8344
+ *
8345
+ * The fix loop already runs the project's own test command and checks, so
8346
+ * what reaches review is code that compiles, lints and passes. None of that
8347
+ * answers whether it is code a reviewer would take: an assertion scoped to
8348
+ * nothing passes, a locator named for a row that matches page-wide text
8349
+ * passes, a test that checks five things the case never asked about passes.
8350
+ * Those are what this reads for, and its findings go back through the same
8351
+ * fix loop rather than to a human.
8352
+ *
8353
+ * Every rule here was calibrated against a large hand-written suite: a rule
8354
+ * that fires on code people wrote and reviewed is a rule that is wrong,
8355
+ * however reasonable it sounds. Two candidates died that way —
8356
+ * "a locator named `…Row` must use `getByRole("row")`" fired across a
8357
+ * sizable share of the hand-written page objects, because that suite names
8358
+ * elements for what they look like rather than for their ARIA role, and
8359
+ * "never `.first()` in an assertion" fired on deliberate uses that each
8360
+ * carried the comment their own guidelines ask for. A third died against
8361
+ * ccqa's own output: the step comment a generated test opens each step with
8362
+ * looked like narration, and is what the evidence table reads back to say
8363
+ * which assertion belongs to which step. Keep that bar for anything added
8364
+ * here.
8365
+ */
8341
8366
  function reviewEmittedFiles(input) {
8342
8367
  const locators = collectLocators(input.files);
8343
8368
  const said = input.caseText.join("\n");
@@ -8346,7 +8371,7 @@ function reviewEmittedFiles(input) {
8346
8371
  ...containerOfPageText(file, source),
8347
8372
  ...unjustifiedFirst(file, source),
8348
8373
  ...describeEcho(file, source),
8349
- ...unaskedAssertions(file, source, locators, said),
8374
+ ...unaskedAssertions(file, source, locators, said, input.blockSteps),
8350
8375
  ...weakerTwin(file, source),
8351
8376
  ...unreached(file, source, input)
8352
8377
  ])];
@@ -8601,9 +8626,26 @@ function assertedStrings(text, locators) {
8601
8626
  * precondition broke rather than four steps later, is worth doing and is
8602
8627
  * written down when it is.
8603
8628
  */
8604
- function unaskedAssertions(file, source, locators, said) {
8629
+ function unaskedAssertions(file, source, locators, said, blockSteps = /* @__PURE__ */ new Set()) {
8605
8630
  const all = lines(source);
8631
+ const boundaries = stepLines(source);
8632
+ let step = null;
8633
+ let depth = null;
8606
8634
  return all.flatMap((text, i) => {
8635
+ const opened = parseStepComment(boundaries[i] ?? "");
8636
+ if (opened !== null) {
8637
+ step = opened;
8638
+ depth = boundaries[i].includes("test.step(") ? 0 : null;
8639
+ }
8640
+ if (depth !== null) {
8641
+ const before = depth;
8642
+ depth += (text.match(/\{/g)?.length ?? 0) - (text.match(/\}/g)?.length ?? 0);
8643
+ if (before > 0 && depth <= 0) {
8644
+ step = null;
8645
+ depth = null;
8646
+ }
8647
+ }
8648
+ if (step !== null && blockSteps.has(step)) return [];
8607
8649
  if (!/\bexpect(?:\.soft)?\(/.test(text)) return [];
8608
8650
  const strings = assertedStrings(text, locators);
8609
8651
  if (strings.length === 0 || strings.some((s) => said.includes(s))) return [];
@@ -15013,12 +15055,14 @@ async function finalizeAndVerify(p) {
15013
15055
  review
15014
15056
  };
15015
15057
  }
15058
+ const FINDINGS_LEFT_OPEN = "generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them";
15016
15059
  /**
15017
15060
  * The runCommand verification loop. One round asks the project's own commands,
15018
- * then the review, then the run — and a round is over when all three are
15019
- * clean. On anything else the output goes to Claude for a corrected set and
15061
+ * then the review and the run together — and a round is over when all three
15062
+ * are clean. On anything else the output goes to Claude for a corrected set and
15020
15063
  * the next round asks all three again: a fix written for a failing run can
15021
- * break a rule the round before it cleared.
15064
+ * break a rule the round before it cleared, and one written for a finding can
15065
+ * break the run.
15022
15066
  *
15023
15067
  * `fix.mode` mirrors the agent-browser target's fix UX (which only ever
15024
15068
  * prompts inside its own fix loop, never for the first write):
@@ -15030,8 +15074,9 @@ async function finalizeAndVerify(p) {
15030
15074
  * verify decides pass/fail.
15031
15075
  *
15032
15076
  * Exhaustion (or a declined / skipped fix) keeps the files on disk and reports
15033
- * `passed: false`. Targets without a runCommand are generate-only here and
15034
- * pass trivially.
15077
+ * `passed: false` — unless an earlier round's files passed their run: those
15078
+ * are put back and the generate passes with the findings open. Targets without
15079
+ * a runCommand are generate-only here and pass trivially.
15035
15080
  */
15036
15081
  async function runVerificationLoop(p, state) {
15037
15082
  const runCommand = p.ctx.targetConfig.runCommand;
@@ -15061,33 +15106,61 @@ async function runVerificationLoop(p, state) {
15061
15106
  const askReviewer = maxRetries > 0;
15062
15107
  let reviewerGone = false;
15063
15108
  if (!askReviewer && p.reading) info("no fix round could act on a review — the reviewer is not asked; the mechanical read still runs");
15109
+ let passing = null;
15110
+ const original = /* @__PURE__ */ new Map();
15111
+ const settle = async (attempt) => {
15112
+ if (passing === null) {
15113
+ await clearCaseRun(p.ctx.ref);
15114
+ return finish(false);
15115
+ }
15116
+ if (passing.attempt !== attempt) {
15117
+ await restoreFiles(state, passing.files, original);
15118
+ review = passing.review;
15119
+ warn("the final round did not pass — restored the files of the last round that did");
15120
+ }
15121
+ warn(FINDINGS_LEFT_OPEN);
15122
+ return finish(true);
15123
+ };
15124
+ if (captures) await clearCaseRun(p.ctx.ref);
15064
15125
  for (let attempt = 0;; attempt++) {
15065
15126
  let failing = await runCheckCommands(p.ctx);
15066
15127
  let asked = [];
15067
15128
  if (failing === null) {
15068
15129
  await refreshFromDisk(state);
15069
- const [reading, mechanical] = await Promise.all([readingOfEmitted(p, state, askReviewer && !reviewerGone), reviewOfEmitted(p.ctx, state, p.writeRoots)]);
15130
+ const questions = [
15131
+ readingOfEmitted(p, state, askReviewer && !reviewerGone),
15132
+ reviewOfEmitted(p.ctx, state, p.writeRoots),
15133
+ verifyRun(p, state, runCommand, attempt, captures)
15134
+ ];
15135
+ const [reading, mechanical, run] = await Promise.all(questions).catch(async (err) => {
15136
+ await Promise.allSettled(questions);
15137
+ await clearCaseRun(p.ctx.ref);
15138
+ throw err;
15139
+ });
15070
15140
  review = reading;
15071
- if (review?.reviewerFailed) reviewerGone = true;
15072
- const unchecked = uncheckedSteps(review, spent);
15073
- const violations = guideViolations(review, spent);
15141
+ if (run.exitCode === 0) passing = {
15142
+ files: new Map(state),
15143
+ review,
15144
+ attempt
15145
+ };
15146
+ const heard = review?.reviewerFailed ? void 0 : review;
15147
+ if (review && !heard) reviewerGone = true;
15148
+ const unchecked = uncheckedSteps(heard, spent);
15149
+ const violations = guideViolations(heard, spent);
15074
15150
  const reads = allReadings(mechanical, unchecked, violations);
15075
- if (reads !== null && attempt < maxRetries) {
15076
- asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
15077
- failing = reads;
15078
- } else {
15079
- if (reads !== null) warn("generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them");
15080
- const run = await verifyRun(p, state, runCommand, attempt, captures);
15081
- if (run.exitCode === 0) return finish(true);
15082
- failing = run;
15151
+ asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
15152
+ if (run.exitCode !== 0) failing = reads === null ? run : allReadings(run, reads) ?? run;
15153
+ else if (reads !== null && attempt < maxRetries) failing = reads;
15154
+ else {
15155
+ if (reads !== null) warn(FINDINGS_LEFT_OPEN);
15156
+ return finish(true);
15083
15157
  }
15084
15158
  }
15085
15159
  const outputTail = scrubEnvValues(tail(failing.output), outputScrub);
15086
15160
  if (attempt >= maxRetries) {
15087
- warn(`verification still failing after ${maxRetries} fix attempt(s) — generated files kept`);
15161
+ warn(`verification still failing after ${maxRetries} fix attempt(s)`);
15088
15162
  logOutputTail(outputTail, warn);
15089
- await clearCaseRun(p.ctx.ref);
15090
- return finish(false);
15163
+ return settle(attempt);
15091
15164
  }
15092
15165
  fix(`verification failed (exit ${failing.exitCode}) — requesting a fix (${attempt + 1}/${maxRetries})`);
15093
15166
  logOutputTail(outputTail, fix);
@@ -15125,9 +15198,12 @@ async function runVerificationLoop(p, state) {
15125
15198
  continue;
15126
15199
  }
15127
15200
  if (p.ctx.fix.mode === "interactive" && !await confirmFixWrite(output.files, p.ctx.cwd)) {
15128
- info("fix not applied (declined) — keeping current files");
15129
- await clearCaseRun(p.ctx.ref);
15130
- return finish(false);
15201
+ info("fix not applied (declined)");
15202
+ return settle(attempt);
15203
+ }
15204
+ for (const f of output.files) {
15205
+ const rel = relative(p.ctx.cwd, resolve(p.ctx.cwd, f.path));
15206
+ if (!state.has(rel) && !original.has(rel)) original.set(rel, await readFile(resolve(p.ctx.cwd, f.path), "utf8").catch(() => null));
15131
15207
  }
15132
15208
  await writeGeneratedFiles(p.ctx.cwd, output.files, state);
15133
15209
  review = void 0;
@@ -15135,17 +15211,12 @@ async function runVerificationLoop(p, state) {
15135
15211
  }
15136
15212
  /**
15137
15213
  * One verification run of the files as they are now, in the shape the fix loop
15138
- * consumes. The round's last question and its most expensive: it is asked only
15139
- * of files the project's own commands and the review have already cleared.
15214
+ * consumes. Asked only of files the project's own commands have cleared.
15140
15215
  */
15141
15216
  async function verifyRun(p, state, runCommand, attempt, captures) {
15142
15217
  const testFiles = [...state.entries()].filter(([, f]) => f.kind === "test").map(([rel]) => rel);
15143
15218
  const artifactsDir = await mkdtemp(join(tmpdir(), "ccqa-verify-artifacts-"));
15144
- const evidenceDir = captures ? caseRunDir(p.ctx.ref) : null;
15145
- if (evidenceDir) {
15146
- await clearCaseRun(p.ctx.ref);
15147
- await mkdir(evidenceDir, { recursive: true });
15148
- }
15219
+ const evidenceDir = captures ? await mkdtemp(join(tmpdir(), "ccqa-verify-evidence-")) : null;
15149
15220
  let command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
15150
15221
  let traceUnavailable;
15151
15222
  if (evidenceDir) {
@@ -15169,6 +15240,8 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
15169
15240
  warn: (message) => warn(`${p.ctx.ref.id}: ${message}`)
15170
15241
  });
15171
15242
  if (unavailable) warn(unavailable);
15243
+ await clearCaseRun(p.ctx.ref);
15244
+ await cp(evidenceDir, caseRunDir(p.ctx.ref), { recursive: true });
15172
15245
  }
15173
15246
  return {
15174
15247
  ...result,
@@ -15179,6 +15252,10 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
15179
15252
  recursive: true,
15180
15253
  force: true
15181
15254
  });
15255
+ if (evidenceDir) await rm(evidenceDir, {
15256
+ recursive: true,
15257
+ force: true
15258
+ });
15182
15259
  }
15183
15260
  }
15184
15261
  /**
@@ -15199,6 +15276,10 @@ async function runCheckCommands(ctx) {
15199
15276
  }
15200
15277
  return null;
15201
15278
  }
15279
+ function blockStepIds(ctx) {
15280
+ const included = new Set(collectIncludedBlockNames(ctx.spec));
15281
+ return new Set(ctx.steps.filter((s) => included.has(s.source)).map((s) => s.id));
15282
+ }
15202
15283
  /**
15203
15284
  * The mechanical read of what this attempt wrote, shaped like a failed check
15204
15285
  * so the fix loop carries it the same way.
@@ -15216,6 +15297,7 @@ async function reviewOfEmitted(ctx, state, writeRoots) {
15216
15297
  ...ctx.expectations,
15217
15298
  ...ctx.cleanupExpectations
15218
15299
  ],
15300
+ blockSteps: blockStepIds(ctx),
15219
15301
  testPath: ctx.testPath
15220
15302
  });
15221
15303
  if (findings.length === 0) return null;
@@ -15261,6 +15343,23 @@ async function refreshFromDisk(state) {
15261
15343
  }
15262
15344
  }
15263
15345
  /**
15346
+ * Put the files back as `to` holds them. A path written after `to` was taken
15347
+ * goes back to what it held before the loop wrote it, or away if it was new.
15348
+ */
15349
+ async function restoreFiles(state, to, original) {
15350
+ for (const [rel, f] of state) {
15351
+ if (to.has(rel)) continue;
15352
+ const was = original.get(rel) ?? null;
15353
+ if (was === null) await rm(f.abs, { force: true });
15354
+ else await writeFile(f.abs, was, "utf8");
15355
+ }
15356
+ state.clear();
15357
+ for (const [rel, f] of to) {
15358
+ await writeFile(f.abs, f.contents, "utf8");
15359
+ state.set(rel, f);
15360
+ }
15361
+ }
15362
+ /**
15264
15363
  * The reading's findings, shaped like a failed check so the fix loop carries
15265
15364
  * them the same way — or null when there is nothing to act on.
15266
15365
  *
@@ -15273,9 +15372,8 @@ async function refreshFromDisk(state) {
15273
15372
  *
15274
15373
  * What the round asks for is verified like any other rewrite, and that can
15275
15374
  * end red: an assertion strengthened to check what the case claims may simply
15276
- * not hold. The generate then reports failed — on the test run, not on this —
15277
- * and the files are kept. That is the honest outcome, and the alternative
15278
- * (restoring what passed weakly) would hide a case the product does not meet.
15375
+ * not hold. The loop then goes back to the last version that passed, and the
15376
+ * finding stays open on the record rather than hidden.
15279
15377
  *
15280
15378
  * A review that could not be obtained is not acted on either: spending a round
15281
15379
  * answering a question nobody asked is worse than leaving it unanswered. Nor
@@ -15421,7 +15519,7 @@ function allReadings(...reports) {
15421
15519
  const found = reports.filter((r) => r !== null);
15422
15520
  if (found.length === 0) return null;
15423
15521
  return {
15424
- exitCode: 1,
15522
+ exitCode: found[0].exitCode,
15425
15523
  command: found.map((r) => r.command).join(" + "),
15426
15524
  output: found.map((r) => `${r.command}\n\n${r.output}`).join("\n\n")
15427
15525
  };
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.60.1",
3
+ "version": "1.60.2",
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.60.1",
3
+ "version": "1.60.2",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {