ccqa 1.23.0 → 1.24.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
@@ -4556,6 +4556,7 @@ const PerspectiveSpecSchema = z.object({
4556
4556
  testCondition: z.string().optional(),
4557
4557
  preconditions: z.array(z.string().min(1)).optional(),
4558
4558
  status: PerspectiveStatusSchema,
4559
+ changedAt: z.string().optional(),
4559
4560
  note: z.string().optional()
4560
4561
  }).strip();
4561
4562
  const PerspectiveFeatureSchema = z.object({
@@ -7038,6 +7039,7 @@ const SpecRerunSchema = z.object({
7038
7039
  driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
7039
7040
  auditAssumedReached: RerunUnknownReasonSchema.optional(),
7040
7041
  executionAssumedReached: RerunUnknownReasonSchema.optional(),
7042
+ specChangedSince: z.string().optional(),
7041
7043
  heldBy: SpecLockSchema.nullable(),
7042
7044
  lastRun: SpecLedgerEntrySchema.nullable(),
7043
7045
  lastGreen: SpecLedgerEntrySchema.nullable(),
@@ -16180,6 +16182,53 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
16180
16182
  `;
16181
16183
  }
16182
16184
  //#endregion
16185
+ //#region src/spec/spec-changed-at.ts
16186
+ /**
16187
+ * One `git log` over the spec tree, walked newest-first. The first time a spec's
16188
+ * directory appears is that spec's last edit.
16189
+ *
16190
+ * Best-effort: outside a repository (or with no history) this returns an empty
16191
+ * map and every caller falls back to what it did before. A missing timestamp
16192
+ * must never make a spec look fresher than it is.
16193
+ */
16194
+ async function readSpecChangedAt(cwd) {
16195
+ const out = /* @__PURE__ */ new Map();
16196
+ let stdout;
16197
+ try {
16198
+ ({stdout} = await execFileP("git", [
16199
+ "log",
16200
+ "--pretty=format:%x00%cI",
16201
+ "--name-only",
16202
+ "--",
16203
+ ".ccqa/features"
16204
+ ], {
16205
+ cwd,
16206
+ maxBuffer: 64 * 1024 * 1024
16207
+ }));
16208
+ } catch {
16209
+ return out;
16210
+ }
16211
+ let when = "";
16212
+ for (const line of stdout.split("\n")) {
16213
+ if (line.startsWith("\0")) {
16214
+ when = line.slice(1).trim();
16215
+ continue;
16216
+ }
16217
+ const key = specKeyOf(line.trim());
16218
+ if (key && when && !out.has(key)) out.set(key, when);
16219
+ }
16220
+ return out;
16221
+ }
16222
+ /**
16223
+ * "feature/spec" for a path under the spec tree, or null for anything else.
16224
+ * Every file in a case's directory counts: the generated code moving is as
16225
+ * much a change to the test as the spec.yaml moving.
16226
+ */
16227
+ function specKeyOf(path) {
16228
+ const m = /^\.ccqa\/features\/([^/]+)\/test-cases\/([^/]+)\//.exec(path);
16229
+ return m ? `${m[1]}/${m[2]}` : null;
16230
+ }
16231
+ //#endregion
16183
16232
  //#region src/cli/perspectives.ts
16184
16233
  const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("-y, --yes", "Apply without asking [y/N]", false).option("--verify", "Check the hub document against the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
16185
16234
  await withCostReporting("perspectives", () => opts.verify ? runPerspectivesCheck(opts) : runPerspectives(opts));
@@ -16330,17 +16379,20 @@ async function cleanupLegacyLocalFiles() {
16330
16379
  */
16331
16380
  async function buildSkeleton(tree) {
16332
16381
  const config = await loadProjectConfig(process.cwd()).catch(() => null);
16382
+ const changedAt = await readSpecChangedAt(process.cwd());
16333
16383
  return (await Promise.all(tree.map(async (feature) => {
16334
16384
  const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
16335
16385
  const specYaml = await tryReadSpecFile(feature.featureName, s.specName);
16336
16386
  const meta = readSpecMeta(s.specName, specYaml);
16337
16387
  const plugin = resolveSpecTarget(specYaml, config);
16338
16388
  const status = await deriveStatus(feature.featureName, s.specName, meta.mode, plugin);
16389
+ const lastEdit = changedAt.get(`${feature.featureName}/${s.specName}`);
16339
16390
  return {
16340
16391
  specName: s.specName,
16341
16392
  title: meta.title,
16342
16393
  summary: "",
16343
- status
16394
+ status,
16395
+ ...lastEdit ? { changedAt: lastEdit } : {}
16344
16396
  };
16345
16397
  }));
16346
16398
  return {
@@ -17838,7 +17890,11 @@ function readSpecTargets(doc) {
17838
17890
  for (const spec of specs) {
17839
17891
  const specName = prop(spec, "specName");
17840
17892
  if (typeof specName !== "string") continue;
17841
- out.push({ key: `${featureName}/${specName}` });
17893
+ const changedAt = prop(spec, "changedAt");
17894
+ out.push({
17895
+ key: `${featureName}/${specName}`,
17896
+ ...typeof changedAt === "string" && changedAt ? { changedAt } : {}
17897
+ });
17842
17898
  }
17843
17899
  }
17844
17900
  return out;
@@ -17959,9 +18015,40 @@ function createReleaseLocksHandler(storage) {
17959
18015
  }
17960
18016
  //#endregion
17961
18017
  //#region src/hub/core/rerun.ts
18018
+ /**
18019
+ * When each deployed commit reached the environment. A baseline read at that
18020
+ * commit cannot have seen anything committed after it was deployed.
18021
+ */
18022
+ function deployedAt(log) {
18023
+ const out = /* @__PURE__ */ new Map();
18024
+ for (const entry of log.entries) out.set(entry.sha, entry.at);
18025
+ return out;
18026
+ }
18027
+ /**
18028
+ * Has the spec moved since the baseline was taken?
18029
+ *
18030
+ * A verdict is a claim about a (spec, product) pair, so either side moving
18031
+ * invalidates it. The deploy log covers the product side; this covers the
18032
+ * other one. Without it a spec repaired and merged stays `needsRepair` until
18033
+ * a deploy happens to reach it, and a run that passed against the previous
18034
+ * spec keeps answering `verified` for the new one.
18035
+ *
18036
+ * Compared against when the baseline commit was *deployed*, not when the audit
18037
+ * or run happened: the tree read at that commit predates its deployment, so an
18038
+ * edit after it is definitely not in it. Falls back to the baseline's own
18039
+ * timestamp when the log cannot place the commit.
18040
+ *
18041
+ * One-directional. A later edit time proves the baseline is stale; an earlier
18042
+ * one proves nothing, and this answers false rather than guessing.
18043
+ */
18044
+ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
18045
+ if (!changedAt) return null;
18046
+ return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
18047
+ }
17962
18048
  function computeRerun(input) {
17963
18049
  const { specs, ledger, log, touchIndex, drift, locks, now } = input;
17964
18050
  const range = buildRange(log, touchIndex);
18051
+ const deployTimes = deployedAt(log);
17965
18052
  const out = {};
17966
18053
  for (const spec of specs) {
17967
18054
  const coords = {
@@ -17969,11 +18056,17 @@ function computeRerun(input) {
17969
18056
  lastGreen: ledger.green[spec.key] ?? null,
17970
18057
  lastRed: ledger.red[spec.key] ?? null
17971
18058
  };
17972
- const audit = auditState(drift, spec.key, range);
17973
- const execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
18059
+ let audit = auditState(drift, spec.key, range);
18060
+ let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
18061
+ const driftEntry = drift.specs[spec.key];
18062
+ const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
18063
+ const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
18064
+ if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
18065
+ if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
17974
18066
  const held = heldBy(locks, spec.key, now);
17975
18067
  out[spec.key] = {
17976
18068
  verdict: decide(audit.audit, execution.execution, held),
18069
+ ...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
17977
18070
  ...audit,
17978
18071
  ...execution,
17979
18072
  heldBy: held,
@@ -286,6 +286,7 @@ declare const RerunReportSchema: z.ZodObject<{
286
286
  deployedShaNotInLog: "deployedShaNotInLog";
287
287
  gapInRange: "gapInRange";
288
288
  }>>;
289
+ specChangedSince: z.ZodOptional<z.ZodString>;
289
290
  heldBy: z.ZodNullable<z.ZodObject<{
290
291
  kind: z.ZodEnum<{
291
292
  run: "run";
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.23.0",
3
+ "version": "1.24.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.23.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {