ccqa 1.4.0 → 1.5.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
@@ -3161,12 +3161,45 @@ function hashTriageUserPrompt(text) {
3161
3161
  */
3162
3162
  const CHANGED_FILE_DIFF_TOOL = "mcp__diff__changed_file_diff";
3163
3163
  function buildFailureAnalysisPrompt(input) {
3164
- const { script, specYaml, failureLog, liveTranscriptExcerpt, diffPatch, changedFiles, baseRef, driftIssues, outputLanguage = "auto", triageUserPrompt, customPrompt } = input;
3164
+ const { script, specYaml, failureLog, liveTranscriptExcerpt, diffPatch, changedFiles, baseRef, baseSource = null, range = null, driftIssues, outputLanguage = "auto", triageUserPrompt, customPrompt } = input;
3165
+ const lastGreen = baseSource === "last-green";
3165
3166
  const triageUserPromptBlock = buildTriageUserPromptBlock(triageUserPrompt);
3166
3167
  const customPromptBlock = buildCustomPromptBlock(customPrompt);
3167
- return `You are analyzing a failing E2E regression test right after a source change landed. Your job is a root-cause CALL, not a fix: decide which of three categories explains the failure, using the source diff as your primary context.
3168
+ const languageBlock = outputLanguageBlock(outputLanguage, "`reasoning`, `detail`", "label names (TEST_DRIFT, etc.)");
3169
+ const executionBlock = buildExecutionEvidenceBlock(script, failureLog, liveTranscriptExcerpt);
3170
+ const baseLabel = lastGreen ? `this spec's last passing commit${baseRef && baseRef !== "last-green" ? ` (${baseRef})` : ""}` : baseRef ?? "base";
3171
+ const rangeNote = range ? ` — spans ${range.commitCount} commit${range.commitCount === 1 ? "" : "s"} over ${range.days} day${range.days === 1 ? "" : "s"}` : "";
3172
+ let diffBlock;
3173
+ if (diffPatch === null) diffBlock = `## Source changes
3168
3174
 
3169
- ${outputLanguageBlock(outputLanguage, "`reasoning`, `detail`", "label names (TEST_DRIFT, etc.)")}## The three categories
3175
+ No diff context is available (the base ref could not be resolved, or there are no changes). Classify from the failure log, the spec, and what you can read in the repository — and be correspondingly more conservative: prefer UNKNOWN over a confident SPEC_CHANGE/PRODUCT_BUG call without diff evidence.
3176
+ `;
3177
+ else if (diffPatch.length === 0) diffBlock = `## Source changes since ${baseLabel}${rangeNote}
3178
+
3179
+ ### Changed files (name-status)
3180
+ ${changedFiles && changedFiles.length > 0 ? changedFiles : "(no changes in range)"}
3181
+
3182
+ No changed file matches this spec's relatedPaths, so no hunks are inlined. "No related change" is a real signal — but before concluding, scan the name-status list for anything that could plausibly reach this spec and fetch its hunk with \`${CHANGED_FILE_DIFF_TOOL}\`.
3183
+ `;
3184
+ else diffBlock = `## Source changes since ${baseLabel}${rangeNote} (git diff, scoped to this spec's relatedPaths, may be truncated)
3185
+
3186
+ ### Changed files (name-status)
3187
+ ${changedFiles ?? "(unavailable)"}
3188
+
3189
+ ### Patch
3190
+ \`\`\`diff
3191
+ ${diffPatch}
3192
+ \`\`\`
3193
+ `;
3194
+ const driftBlock = driftIssues && driftIssues.length > 0 ? `## Spec↔code drift audit findings
3195
+
3196
+ A separate read-only audit compared the spec against the current source. Treat these as hints, not verdicts:
3197
+
3198
+ ${driftIssues.map((i) => `- [${i.severity}] (${DRAFT_CATEGORY_LABEL[i.category]}${i.stepId ? `, step ${i.stepId}` : ""}) ${i.message}${i.detail ? ` — ${i.detail}` : ""}`).join("\n")}
3199
+ ` : "";
3200
+ return `You are analyzing a failing E2E regression test against the source changes since a known-good baseline. Your job is a root-cause CALL, not a fix: decide which of three categories explains the failure, using the source diff as your primary context.
3201
+
3202
+ ${languageBlock}## The three categories
3170
3203
 
3171
3204
  The question that separates them: **is the behavior the spec describes still what the product intends?**
3172
3205
 
@@ -3189,10 +3222,13 @@ You have **up to 12 tool turns**. Do NOT write, edit, run shell commands, or hit
3189
3222
 
3190
3223
  ## Decision guidance
3191
3224
 
3225
+ ${lastGreen ? `The baseline is the commit where THIS spec last passed, so the range strictly covers the window in which it broke: the cause is either inside these changes or outside the code entirely (flaky timing, environment, an external service, test data). The range may mix several unrelated merges — most of the diff is noise; what matters is the specific change you can tie to the failing step.` : `The baseline is a fixed ref (typically the PR base): the spec is NOT guaranteed to have passed there, so the range is not guaranteed to contain the cause.`}
3226
+
3192
3227
  - Diff touches only attributes/identifiers the test selects on (labels, testids, class names, timing) while the user-visible flow is intact → TEST_DRIFT.
3193
3228
  - Diff intentionally removes/reworks the UI or flow that a spec step verifies (component deleted, page restructured, copy redefined, feature flag flipped) → SPEC_CHANGE.
3194
3229
  - Diff UNINTENTIONALLY breaks behavior the spec still intends — e.g. a refactor that drops a side effect, an inverted condition, a regression hiding inside a cleanup commit — → PRODUCT_BUG, citing the diff hunk as evidence. A product bug is often introduced BY the diff; what separates it from SPEC_CHANGE is intent: does the change read as a deliberate redesign of what the spec verifies, or as collateral damage?
3195
- - Diff is unrelated to the failing step (or there is no relevant diff) and the test was passing before → lean PRODUCT_BUG; first rule out timing/data flakiness and infrastructure errors (daemon not running, network down, missing credentials) — those read as UNKNOWN with low confidence, not PRODUCT_BUG.
3230
+ ${lastGreen ? `- No change in the range explains the failing step (after checking the inline patch, the name-status list, and any hunks you fetched) → the cause is outside the code: answer UNKNOWN with low confidence and name the suspected external cause (flaky timing, environment, external service, test data). Do NOT default to PRODUCT_BUG here — under this baseline a product regression must be tied to an in-range change.` : `- Diff is unrelated to the failing step (or there is no relevant diff) and the test was passing before → lean PRODUCT_BUG; first rule out timing/data flakiness and infrastructure errors (daemon not running, network down, missing credentials) — those read as UNKNOWN with low confidence, not PRODUCT_BUG.`}${range ? `
3231
+ - This range spans ${range.commitCount} commit${range.commitCount === 1 ? "" : "s"} over ${range.days} day${range.days === 1 ? "" : "s"}. The wider the range, the more unrelated changes are mixed in: SPEC_CHANGE and TEST_DRIFT still require citing the specific hunk — do not infer intent from the bulk of a large diff, and lower confidence when the evidence is spread thin.` : ""}
3196
3232
  - The drift audit findings (when present) flag spec↔code mismatches; an ERROR there usually supports TEST_DRIFT or SPEC_CHANGE over PRODUCT_BUG.
3197
3233
 
3198
3234
  ## Sub-diagnosis vocabulary
@@ -3232,32 +3268,15 @@ Your **final** assistant message must start with \`{\` and end with \`}\` — a
3232
3268
  - 0.4-0.7: plausible but another category could explain it
3233
3269
  - < 0.4: answer UNKNOWN instead of guessing
3234
3270
 
3235
- Evidence rules: TEST_DRIFT and SPEC_CHANGE require at least one concrete \`file\` reference (diff hunk or file:line you actually read). PRODUCT_BUG should explain why the diff does NOT account for the failure.
3271
+ Evidence rules: TEST_DRIFT and SPEC_CHANGE require at least one concrete \`file\` reference (diff hunk or file:line you actually read). PRODUCT_BUG should cite the in-range change that unintentionally broke the behavior when one exists; ${lastGreen ? "under this last-green baseline, if no in-range change explains the failure, that is UNKNOWN (external cause), not PRODUCT_BUG" : "when no such change exists, explain why the diff does NOT account for the failure"}.
3236
3272
 
3237
3273
  ## Test Spec (spec.yaml)
3238
3274
  ${specYaml}
3239
3275
 
3240
- ${buildExecutionEvidenceBlock(script, failureLog, liveTranscriptExcerpt)}
3241
-
3242
- ${diffPatch ? `## Source changes since ${baseRef ?? "base"} (git diff, may be truncated)
3243
-
3244
- ### Changed files (name-status)
3245
- ${changedFiles ?? "(unavailable)"}
3246
-
3247
- ### Patch
3248
- \`\`\`diff
3249
- ${diffPatch}
3250
- \`\`\`
3251
- ` : `## Source changes
3276
+ ${executionBlock}
3252
3277
 
3253
- No diff context is available (the base ref could not be resolved, or there are no changes). Classify from the failure log, the spec, and what you can read in the repository — and be correspondingly more conservative: prefer UNKNOWN over a confident SPEC_CHANGE/PRODUCT_BUG call without diff evidence.
3254
- `}
3255
- ${driftIssues && driftIssues.length > 0 ? `## Spec↔code drift audit findings
3256
-
3257
- A separate read-only audit compared the spec against the current source. Treat these as hints, not verdicts:
3258
-
3259
- ${driftIssues.map((i) => `- [${i.severity}] (${DRAFT_CATEGORY_LABEL[i.category]}${i.stepId ? `, step ${i.stepId}` : ""}) ${i.message}${i.detail ? ` — ${i.detail}` : ""}`).join("\n")}
3260
- ` : ""}`;
3278
+ ${diffBlock}
3279
+ ${driftBlock}`;
3261
3280
  }
3262
3281
  /**
3263
3282
  * Render the execution-evidence section the model needs to classify the
@@ -3671,17 +3690,20 @@ function splitPatchByFile(patch) {
3671
3690
  /**
3672
3691
  * Scope a full patch down to the files a spec depends on, then truncate so
3673
3692
  * the analysis prompt stays bounded. `relatedPaths` null/empty means the
3674
- * spec is unscoped — keep the whole patch (still truncated). Callers scoping
3675
- * the same patch for many specs can pass pre-split sections instead.
3693
+ * spec is unscoped — keep the whole patch (still truncated). When
3694
+ * relatedPaths are declared but nothing in the diff matches, the result is
3695
+ * the empty string: "no related change" is itself a signal the prompt
3696
+ * renders explicitly, and the model can inspect any unmatched file's hunk
3697
+ * via the on-demand diff tool — inlining the full unrelated diff (the old
3698
+ * fallback) just burned the prompt budget, especially under wide last-green
3699
+ * baselines. Callers scoping the same patch for many specs can pass
3700
+ * pre-split sections instead.
3676
3701
  */
3677
3702
  function scopePatchForSpec(patch, relatedPaths, caps = {}) {
3678
3703
  const perFile = caps.perFile ?? 8192;
3679
3704
  const total = caps.total ?? 49152;
3680
3705
  let sections = typeof patch === "string" ? splitPatchByFile(patch) : patch;
3681
- if (relatedPaths && relatedPaths.length > 0) {
3682
- const scoped = sections.filter((s) => isPathAffectedBy(s.path, relatedPaths));
3683
- if (scoped.length > 0) sections = scoped;
3684
- }
3706
+ if (relatedPaths && relatedPaths.length > 0) sections = sections.filter((s) => isPathAffectedBy(s.path, relatedPaths));
3685
3707
  const parts = [];
3686
3708
  let used = 0;
3687
3709
  let droppedFiles = 0;
@@ -3716,6 +3738,40 @@ function lookupFileDiff(sections, path) {
3716
3738
  if (section.body.length <= 16384) return section.body;
3717
3739
  return `${section.body.slice(0, FILE_DIFF_RESPONSE_CAP)}\n[truncated: ${section.body.length - FILE_DIFF_RESPONSE_CAP} more chars — Read the file for its full current state]`;
3718
3740
  }
3741
+ /**
3742
+ * Best-effort width of the base..HEAD range. Two-dot rev-list matches what
3743
+ * the three-dot diff shows: commits on the HEAD side since the merge base.
3744
+ */
3745
+ async function measureRange(sha, cwd) {
3746
+ try {
3747
+ const [{ stdout: count }, { stdout: baseTime }, { stdout: headTime }] = await Promise.all([
3748
+ execFileP("git", [
3749
+ "rev-list",
3750
+ "--count",
3751
+ `${sha}..HEAD`
3752
+ ], { cwd }),
3753
+ execFileP("git", [
3754
+ "log",
3755
+ "-1",
3756
+ "--format=%ct",
3757
+ sha
3758
+ ], { cwd }),
3759
+ execFileP("git", [
3760
+ "log",
3761
+ "-1",
3762
+ "--format=%ct",
3763
+ "HEAD"
3764
+ ], { cwd })
3765
+ ]);
3766
+ const seconds = Number(headTime.trim()) - Number(baseTime.trim());
3767
+ return {
3768
+ commitCount: Number(count.trim()),
3769
+ days: Math.max(0, Math.round(seconds / 86400))
3770
+ };
3771
+ } catch {
3772
+ return null;
3773
+ }
3774
+ }
3719
3775
  function createDiffProvider(args) {
3720
3776
  const { resolveBase, cwd } = args;
3721
3777
  const captures = /* @__PURE__ */ new Map();
@@ -3724,17 +3780,19 @@ function createDiffProvider(args) {
3724
3780
  const cached = captures.get(sha);
3725
3781
  if (cached) return cached;
3726
3782
  const pending = (async () => {
3727
- const result = await capturePrDiff(sha, cwd);
3783
+ const [result, range] = await Promise.all([capturePrDiff(sha, cwd), measureRange(sha, cwd)]);
3728
3784
  if (!result.ok) return {
3729
3785
  sections: null,
3730
3786
  nameStatus: null,
3731
- error: result.error
3787
+ error: result.error,
3788
+ range
3732
3789
  };
3733
3790
  const { patch, nameStatus } = result.diff;
3734
3791
  return {
3735
3792
  sections: patch.length > 0 ? splitPatchByFile(patch) : [],
3736
3793
  nameStatus,
3737
- error: null
3794
+ error: null,
3795
+ range
3738
3796
  };
3739
3797
  })();
3740
3798
  captures.set(sha, pending);
@@ -3760,6 +3818,7 @@ function createDiffProvider(args) {
3760
3818
  patch: sections ? scopePatchForSpec(sections, scope) : null,
3761
3819
  nameStatus: captured.nameStatus,
3762
3820
  error: captured.error,
3821
+ range: captured.range,
3763
3822
  fileDiff: (path) => sections ? lookupFileDiff(sections, path) : null
3764
3823
  };
3765
3824
  } };
@@ -6146,6 +6205,8 @@ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts,
6146
6205
  diffPatch: specDiff.patch,
6147
6206
  changedFiles: specDiff.nameStatus,
6148
6207
  baseRef: specDiff.base.ref,
6208
+ baseSource: specDiff.base.source,
6209
+ range: specDiff.range,
6149
6210
  driftIssues: driftForSpec,
6150
6211
  ...opts.language ? { outputLanguage: opts.language } : {},
6151
6212
  ...opts.triageUserPrompt ? { triageUserPrompt: opts.triageUserPrompt } : {},
@@ -9823,6 +9884,8 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9823
9884
  diffPatch: diffExcerpt,
9824
9885
  changedFiles: specDiffResult.nameStatus,
9825
9886
  baseRef: specDiffResult.base.ref,
9887
+ baseSource: specDiffResult.base.source,
9888
+ range: specDiffResult.range,
9826
9889
  driftIssues,
9827
9890
  ...opts.language ? { outputLanguage: opts.language } : {},
9828
9891
  ...triageUserPrompt ? { triageUserPrompt } : {},
@@ -9888,7 +9951,7 @@ function buildReportEnvelope(args) {
9888
9951
  },
9889
9952
  model: opts.model ?? null,
9890
9953
  language: opts.language ?? null,
9891
- promptVersion: "5",
9954
+ promptVersion: "6",
9892
9955
  customPromptVersion,
9893
9956
  ...triageUserPromptHash !== null ? { triageUserPromptHash } : {}
9894
9957
  };
@@ -18409,7 +18472,7 @@ function createLearningWorker(deps) {
18409
18472
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
18410
18473
  const customPrompt = {
18411
18474
  schemaVersion: 1,
18412
- basePromptVersion: "5",
18475
+ basePromptVersion: "6",
18413
18476
  customPromptVersion: `${generatedAt}-c${cases.length}`,
18414
18477
  generatedAt,
18415
18478
  guidance
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.4.0",
3
+ "version": "1.5.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.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {