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 +97 -4
- package/dist/hub-client/index.d.mts +1 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
17973
|
-
|
|
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