ccqa 1.22.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 +222 -92
- 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(),
|
|
@@ -15315,16 +15317,18 @@ const DEFAULT_CONCURRENCY$1 = 3;
|
|
|
15315
15317
|
* `cli/run` calls this with just the failing specs after vitest.
|
|
15316
15318
|
*/
|
|
15317
15319
|
async function analyzeDrift(input) {
|
|
15318
|
-
const { targets, cwd, blocks, concurrency = DEFAULT_CONCURRENCY$1, model, language, guidance, onSpecStart } = input;
|
|
15320
|
+
const { targets, cwd, blocks, concurrency = DEFAULT_CONCURRENCY$1, model, language, guidance, onSpecStart, onSpecDone } = input;
|
|
15319
15321
|
return runPool(targets, concurrency, async (target) => {
|
|
15320
15322
|
onSpecStart?.(target);
|
|
15321
|
-
|
|
15323
|
+
const result = await checkSpec(target, {
|
|
15322
15324
|
cwd,
|
|
15323
15325
|
blocks,
|
|
15324
15326
|
model,
|
|
15325
15327
|
language,
|
|
15326
15328
|
guidance
|
|
15327
15329
|
});
|
|
15330
|
+
await onSpecDone?.(result);
|
|
15331
|
+
return result;
|
|
15328
15332
|
});
|
|
15329
15333
|
}
|
|
15330
15334
|
async function checkSpec(target, opts) {
|
|
@@ -15513,37 +15517,35 @@ function specStatus(result, threshold) {
|
|
|
15513
15517
|
return "passed";
|
|
15514
15518
|
}
|
|
15515
15519
|
/**
|
|
15516
|
-
*
|
|
15517
|
-
*
|
|
15518
|
-
*
|
|
15519
|
-
*
|
|
15520
|
-
*
|
|
15521
|
-
*
|
|
15520
|
+
* One audited spec as a report row. Browser-execution fields don't apply to an
|
|
15521
|
+
* audit and stay empty — which is why `mode` is carried separately: nothing
|
|
15522
|
+
* ran, but which surfaces were audited is still a fact about the row. The
|
|
15523
|
+
* diagnosis goes into `analysis` because for a `kind: "drift"` report the
|
|
15524
|
+
* diagnosis IS the row's verdict, so it renders through the same card a failed
|
|
15525
|
+
* `ccqa run` spec does.
|
|
15522
15526
|
*
|
|
15523
|
-
*
|
|
15524
|
-
*
|
|
15525
|
-
* card a failed `ccqa run` spec does. `reasoning` has no drift-audit
|
|
15526
|
-
* equivalent (the audit gives one headline, not a deliberation) so it is
|
|
15527
|
-
* filled with an empty string to satisfy `FailureAnalysisSchema`.
|
|
15527
|
+
* Shared by the incremental push and the final report: the hub upserts by
|
|
15528
|
+
* feature/spec, so two mappings would let the closing patch rewrite history.
|
|
15528
15529
|
*/
|
|
15529
|
-
function
|
|
15530
|
-
|
|
15531
|
-
|
|
15532
|
-
|
|
15533
|
-
|
|
15530
|
+
function driftResultToRow(result, threshold) {
|
|
15531
|
+
return {
|
|
15532
|
+
...emptySpecRow({
|
|
15533
|
+
feature: result.target.featureName,
|
|
15534
|
+
spec: result.target.specName,
|
|
15535
|
+
title: result.title ?? null,
|
|
15536
|
+
status: specStatus(result, threshold)
|
|
15537
|
+
}),
|
|
15534
15538
|
...result.live === void 0 ? {} : { mode: result.live ? "live" : "deterministic" },
|
|
15535
|
-
|
|
15536
|
-
|
|
15537
|
-
|
|
15538
|
-
|
|
15539
|
-
|
|
15540
|
-
|
|
15541
|
-
|
|
15542
|
-
|
|
15543
|
-
|
|
15544
|
-
|
|
15545
|
-
liveRun: null
|
|
15546
|
-
}));
|
|
15539
|
+
analysis: result.drift ?? null
|
|
15540
|
+
};
|
|
15541
|
+
}
|
|
15542
|
+
/**
|
|
15543
|
+
* Adapts `ccqa audit` results into the shared RunReportData shape so they can
|
|
15544
|
+
* be pushed to the hub (`ccqa audit --report-to-hub`) and rendered by the same
|
|
15545
|
+
* report UI as `ccqa run`/`ccqa live`.
|
|
15546
|
+
*/
|
|
15547
|
+
function driftResultsToReport(results, meta) {
|
|
15548
|
+
const specResults = results.map((result) => driftResultToRow(result, meta.threshold));
|
|
15547
15549
|
return {
|
|
15548
15550
|
schemaVersion: 1,
|
|
15549
15551
|
kind: "drift",
|
|
@@ -15823,8 +15825,13 @@ async function runAudit(specPath, opts) {
|
|
|
15823
15825
|
requireReportToHubConnection(opts);
|
|
15824
15826
|
let results;
|
|
15825
15827
|
let promptCtx;
|
|
15828
|
+
let push = null;
|
|
15826
15829
|
try {
|
|
15827
15830
|
promptCtx = await resolveAuditPromptContext(opts, cwd);
|
|
15831
|
+
if (opts.reportToHub) {
|
|
15832
|
+
push = await openDriftPush(opts, cwd);
|
|
15833
|
+
if (format === "text") info(`hub: incremental drift run opened (${push.runId})`);
|
|
15834
|
+
}
|
|
15828
15835
|
results = await analyzeDrift({
|
|
15829
15836
|
targets,
|
|
15830
15837
|
cwd,
|
|
@@ -15835,16 +15842,18 @@ async function runAudit(specPath, opts) {
|
|
|
15835
15842
|
guidance: promptCtx.guidance,
|
|
15836
15843
|
onSpecStart: (t) => {
|
|
15837
15844
|
if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
|
|
15845
|
+
},
|
|
15846
|
+
onSpecDone: async (r) => {
|
|
15847
|
+
if (push) await sendDriftRow(push, r, threshold);
|
|
15838
15848
|
}
|
|
15839
15849
|
});
|
|
15840
15850
|
} finally {
|
|
15841
15851
|
if (holder) await releaseSpecs(hub, hubProject, opts.hubProfile, holder);
|
|
15842
15852
|
}
|
|
15843
15853
|
process.stdout.write(renderDrift(results, format, cwd));
|
|
15844
|
-
if (
|
|
15854
|
+
if (push) await sealDriftPush(push, {
|
|
15845
15855
|
results,
|
|
15846
15856
|
threshold,
|
|
15847
|
-
cwd,
|
|
15848
15857
|
opts,
|
|
15849
15858
|
format,
|
|
15850
15859
|
baseRef,
|
|
@@ -15893,59 +15902,92 @@ function requireReportToHubConnection(opts) {
|
|
|
15893
15902
|
process.exit(2);
|
|
15894
15903
|
}
|
|
15895
15904
|
/**
|
|
15896
|
-
*
|
|
15897
|
-
*
|
|
15898
|
-
*
|
|
15899
|
-
*
|
|
15905
|
+
* Open the drift run this sweep patches into. A failure here is fatal, as it
|
|
15906
|
+
* is for `ccqa run`: a job that asked to publish and cannot reach the hub has
|
|
15907
|
+
* not done what it was told, and the audit has no local artifact to fall back
|
|
15908
|
+
* on — the hub is its only output. Raised before any spec is checked, so
|
|
15909
|
+
* nothing is wasted. Not retried: a dropped response after the hub committed
|
|
15910
|
+
* would leave a second orphan running run.
|
|
15900
15911
|
*
|
|
15901
|
-
* `resolveHub` is injectable
|
|
15902
|
-
* a real hub connection; it defaults to the real flag/env resolution.
|
|
15912
|
+
* `resolveHub` is injectable for tests.
|
|
15903
15913
|
*/
|
|
15904
|
-
async function
|
|
15905
|
-
const { results, threshold, cwd, opts, format, baseRef, promptCtx } = args;
|
|
15914
|
+
async function openDriftPush(opts, cwd, resolveHub = resolveHubClient) {
|
|
15906
15915
|
const hub = resolveHub(opts);
|
|
15907
|
-
if (!hub)
|
|
15908
|
-
|
|
15909
|
-
|
|
15916
|
+
if (!hub) throw new RunUsageError("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
|
|
15917
|
+
const project = resolveProject({
|
|
15918
|
+
project: opts.project,
|
|
15919
|
+
cwd
|
|
15920
|
+
});
|
|
15921
|
+
const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
15922
|
+
const ciRunId = githubRunId();
|
|
15923
|
+
const runUrl = githubRunUrl();
|
|
15924
|
+
try {
|
|
15925
|
+
return {
|
|
15926
|
+
hub,
|
|
15927
|
+
runId: (await hub.openRun({
|
|
15928
|
+
project,
|
|
15929
|
+
kind: "drift",
|
|
15930
|
+
...branch ? { branch } : {},
|
|
15931
|
+
...opts.hubProfile ? { profile: opts.hubProfile } : {},
|
|
15932
|
+
...gitHead ? { gitHead } : {},
|
|
15933
|
+
...ciRunId ? { ciRunId } : {},
|
|
15934
|
+
...runUrl ? { runUrl } : {}
|
|
15935
|
+
})).id,
|
|
15936
|
+
gitHead
|
|
15937
|
+
};
|
|
15938
|
+
} catch (err) {
|
|
15939
|
+
throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
|
|
15910
15940
|
}
|
|
15941
|
+
}
|
|
15942
|
+
/**
|
|
15943
|
+
* Send one finished spec. A failure is warned and swallowed rather than
|
|
15944
|
+
* thrown: the seal resends every row, so a dropped patch costs freshness for
|
|
15945
|
+
* the rest of the sweep, not the record.
|
|
15946
|
+
*/
|
|
15947
|
+
async function sendDriftRow(push, result, threshold) {
|
|
15911
15948
|
try {
|
|
15912
|
-
|
|
15913
|
-
|
|
15914
|
-
|
|
15949
|
+
await push.hub.patchRun(push.runId, {
|
|
15950
|
+
rows: [driftResultToRow(result, threshold)],
|
|
15951
|
+
reportMeta: { cost: currentReportCost() }
|
|
15915
15952
|
});
|
|
15916
|
-
|
|
15917
|
-
|
|
15918
|
-
|
|
15919
|
-
|
|
15920
|
-
|
|
15921
|
-
|
|
15922
|
-
|
|
15923
|
-
|
|
15924
|
-
|
|
15953
|
+
} catch (err) {
|
|
15954
|
+
warn(`hub: could not push ${result.target.featureName}/${result.target.specName}: ${errMessage(err)}`);
|
|
15955
|
+
}
|
|
15956
|
+
}
|
|
15957
|
+
/**
|
|
15958
|
+
* Close the run with every row and the envelope metadata. Resends all rows
|
|
15959
|
+
* rather than only the unsent ones, so this one call also repairs any row
|
|
15960
|
+
* whose mid-sweep patch failed. Status is left to the hub, which derives it
|
|
15961
|
+
* from the rows.
|
|
15962
|
+
*/
|
|
15963
|
+
async function sealDriftPush(push, args) {
|
|
15964
|
+
const { results, threshold, opts, format, baseRef, promptCtx } = args;
|
|
15965
|
+
const report = driftResultsToReport(results, {
|
|
15966
|
+
threshold,
|
|
15967
|
+
git: {
|
|
15968
|
+
head: push.gitHead,
|
|
15969
|
+
base: baseRef ?? null
|
|
15970
|
+
},
|
|
15971
|
+
customPromptVersion: promptCtx?.customPromptVersion ?? null,
|
|
15972
|
+
triageUserPromptHash: promptCtx?.triageUserPromptHash ?? null
|
|
15973
|
+
});
|
|
15974
|
+
try {
|
|
15975
|
+
await push.hub.patchRun(push.runId, {
|
|
15976
|
+
rows: report.results,
|
|
15977
|
+
done: true,
|
|
15978
|
+
reportMeta: {
|
|
15979
|
+
git: report.git,
|
|
15980
|
+
promptVersion: report.promptVersion,
|
|
15981
|
+
customPromptVersion: report.customPromptVersion,
|
|
15982
|
+
...report.triageUserPromptHash ? { triageUserPromptHash: report.triageUserPromptHash } : {},
|
|
15983
|
+
cost: report.cost
|
|
15984
|
+
}
|
|
15925
15985
|
});
|
|
15926
|
-
const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
|
|
15927
|
-
try {
|
|
15928
|
-
await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
|
|
15929
|
-
const archive = await packDirToTarGz(dir);
|
|
15930
|
-
const run = await hub.pushRun(archive, {
|
|
15931
|
-
project,
|
|
15932
|
-
...branch ? { branch } : {},
|
|
15933
|
-
kind: "drift"
|
|
15934
|
-
});
|
|
15935
|
-
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
|
|
15936
|
-
} finally {
|
|
15937
|
-
await rm(dir, {
|
|
15938
|
-
recursive: true,
|
|
15939
|
-
force: true
|
|
15940
|
-
});
|
|
15941
|
-
}
|
|
15942
15986
|
} catch (err) {
|
|
15943
|
-
|
|
15944
|
-
|
|
15945
|
-
process.exit(2);
|
|
15946
|
-
}
|
|
15947
|
-
throw err;
|
|
15987
|
+
error(`hub: could not close the drift run ${push.runId}: ${errMessage(err)}`);
|
|
15988
|
+
process.exit(2);
|
|
15948
15989
|
}
|
|
15990
|
+
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${push.runId}`);
|
|
15949
15991
|
}
|
|
15950
15992
|
/**
|
|
15951
15993
|
* Adapts `AuditOptions` to `resolveHubContext`'s flat option shape. Unlike
|
|
@@ -16140,6 +16182,53 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
|
|
|
16140
16182
|
`;
|
|
16141
16183
|
}
|
|
16142
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
|
|
16143
16232
|
//#region src/cli/perspectives.ts
|
|
16144
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) => {
|
|
16145
16234
|
await withCostReporting("perspectives", () => opts.verify ? runPerspectivesCheck(opts) : runPerspectives(opts));
|
|
@@ -16290,17 +16379,20 @@ async function cleanupLegacyLocalFiles() {
|
|
|
16290
16379
|
*/
|
|
16291
16380
|
async function buildSkeleton(tree) {
|
|
16292
16381
|
const config = await loadProjectConfig(process.cwd()).catch(() => null);
|
|
16382
|
+
const changedAt = await readSpecChangedAt(process.cwd());
|
|
16293
16383
|
return (await Promise.all(tree.map(async (feature) => {
|
|
16294
16384
|
const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
|
|
16295
16385
|
const specYaml = await tryReadSpecFile(feature.featureName, s.specName);
|
|
16296
16386
|
const meta = readSpecMeta(s.specName, specYaml);
|
|
16297
16387
|
const plugin = resolveSpecTarget(specYaml, config);
|
|
16298
16388
|
const status = await deriveStatus(feature.featureName, s.specName, meta.mode, plugin);
|
|
16389
|
+
const lastEdit = changedAt.get(`${feature.featureName}/${s.specName}`);
|
|
16299
16390
|
return {
|
|
16300
16391
|
specName: s.specName,
|
|
16301
16392
|
title: meta.title,
|
|
16302
16393
|
summary: "",
|
|
16303
|
-
status
|
|
16394
|
+
status,
|
|
16395
|
+
...lastEdit ? { changedAt: lastEdit } : {}
|
|
16304
16396
|
};
|
|
16305
16397
|
}));
|
|
16306
16398
|
return {
|
|
@@ -17157,23 +17249,20 @@ function createPatchRunHandler(config) {
|
|
|
17157
17249
|
};
|
|
17158
17250
|
});
|
|
17159
17251
|
if (evidence) for (const [relPath, b64] of Object.entries(evidence)) await config.storage.artifacts.putFile(id, relPath, Buffer.from(b64, "base64"));
|
|
17160
|
-
const patch =
|
|
17161
|
-
status: finalStatus ?? (specs.failed > 0 ? "failed" : "passed"),
|
|
17252
|
+
const patch = {
|
|
17162
17253
|
specs,
|
|
17163
17254
|
costUsd,
|
|
17164
17255
|
...run.kind === "drift" ? { drift: summarizeDrift(mergedResults) } : {},
|
|
17165
|
-
...
|
|
17166
|
-
|
|
17167
|
-
|
|
17168
|
-
|
|
17169
|
-
|
|
17170
|
-
|
|
17256
|
+
...done ? {
|
|
17257
|
+
status: finalStatus ?? (specs.failed > 0 ? "failed" : "passed"),
|
|
17258
|
+
...reportMeta?.git?.head ? { gitHead: reportMeta.git.head } : {},
|
|
17259
|
+
...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {},
|
|
17260
|
+
...await deployHeadMovedDuringRun(config.storage, run) ? { deployedShaAmbiguous: true } : {}
|
|
17261
|
+
} : {}
|
|
17171
17262
|
};
|
|
17172
17263
|
const updated = await config.storage.runs.update(id, patch);
|
|
17173
|
-
|
|
17174
|
-
|
|
17175
|
-
await updateDriftLedger(config.storage, updated, mergedResults);
|
|
17176
|
-
}
|
|
17264
|
+
await updateDriftLedger(config.storage, updated, mergedResults);
|
|
17265
|
+
if (done) await updateSpecLedger(config.storage, updated, mergedResults);
|
|
17177
17266
|
sendJson(ctx.res, 200, updated);
|
|
17178
17267
|
};
|
|
17179
17268
|
}
|
|
@@ -17801,7 +17890,11 @@ function readSpecTargets(doc) {
|
|
|
17801
17890
|
for (const spec of specs) {
|
|
17802
17891
|
const specName = prop(spec, "specName");
|
|
17803
17892
|
if (typeof specName !== "string") continue;
|
|
17804
|
-
|
|
17893
|
+
const changedAt = prop(spec, "changedAt");
|
|
17894
|
+
out.push({
|
|
17895
|
+
key: `${featureName}/${specName}`,
|
|
17896
|
+
...typeof changedAt === "string" && changedAt ? { changedAt } : {}
|
|
17897
|
+
});
|
|
17805
17898
|
}
|
|
17806
17899
|
}
|
|
17807
17900
|
return out;
|
|
@@ -17922,9 +18015,40 @@ function createReleaseLocksHandler(storage) {
|
|
|
17922
18015
|
}
|
|
17923
18016
|
//#endregion
|
|
17924
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
|
+
}
|
|
17925
18048
|
function computeRerun(input) {
|
|
17926
18049
|
const { specs, ledger, log, touchIndex, drift, locks, now } = input;
|
|
17927
18050
|
const range = buildRange(log, touchIndex);
|
|
18051
|
+
const deployTimes = deployedAt(log);
|
|
17928
18052
|
const out = {};
|
|
17929
18053
|
for (const spec of specs) {
|
|
17930
18054
|
const coords = {
|
|
@@ -17932,11 +18056,17 @@ function computeRerun(input) {
|
|
|
17932
18056
|
lastGreen: ledger.green[spec.key] ?? null,
|
|
17933
18057
|
lastRed: ledger.red[spec.key] ?? null
|
|
17934
18058
|
};
|
|
17935
|
-
|
|
17936
|
-
|
|
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" };
|
|
17937
18066
|
const held = heldBy(locks, spec.key, now);
|
|
17938
18067
|
out[spec.key] = {
|
|
17939
18068
|
verdict: decide(audit.audit, execution.execution, held),
|
|
18069
|
+
...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
|
|
17940
18070
|
...audit,
|
|
17941
18071
|
...execution,
|
|
17942
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