ccqa 1.16.0 → 1.17.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/README.md +5 -4
- package/dist/bin/ccqa.mjs +50 -20
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ grade the calls on the hub, and it learns from your grades.
|
|
|
133
133
|
|
|
134
134
|
```
|
|
135
135
|
deploy lands
|
|
136
|
-
├─ ccqa hub deploy record
|
|
136
|
+
├─ ccqa hub deploy record what shipped, which specs it reaches
|
|
137
137
|
├─ ccqa audit --only-hub-audit-needed --report-to-hub
|
|
138
138
|
│ does each spec still describe it?
|
|
139
139
|
└─ ccqa run --only-hub-rerun-needed --on-fail-explain \
|
|
@@ -141,9 +141,10 @@ deploy lands
|
|
|
141
141
|
```
|
|
142
142
|
|
|
143
143
|
The audit costs cents; a live spec costs dollars. Filtering first leaves
|
|
144
|
-
a run whose failures are worth reading.
|
|
145
|
-
|
|
146
|
-
nothing fills the
|
|
144
|
+
a run whose failures are worth reading. `deploy record` decides which specs
|
|
145
|
+
the range reaches unless you pass `--no-select-specs`; a range recorded
|
|
146
|
+
without that decision answers `unanswerable` forever, and nothing fills the
|
|
147
|
+
hole later.
|
|
147
148
|
|
|
148
149
|
| Job | Trigger | Question it answers |
|
|
149
150
|
|---|---|---|
|
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -8805,7 +8805,7 @@ const promptRm = new Command("rm").description("Delete a prompt from the hub.").
|
|
|
8805
8805
|
info(`deleted prompt "${name}" from the hub`);
|
|
8806
8806
|
}));
|
|
8807
8807
|
const promptCommand = new Command("prompt").description("Manage prompt assets (per-flow user/agent guidance, triage user guidance, analysis custom prompt) stored on the hub (fetched automatically by `ccqa run` at run time).").addCommand(promptPush).addCommand(promptLs).addCommand(promptRm);
|
|
8808
|
-
const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --only-hub-rerun-needed`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, there's nothing to diff against: changedPaths is unset and
|
|
8808
|
+
const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --only-hub-rerun-needed`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, there's nothing to diff against: changedPaths is unset and the spec selection is skipped.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option("--no-select-specs", "Record the deploy without deciding which specs it reaches. The entry then becomes a hole in the range — every spec behind it answers 'unanswerable' rather than being cleared, and nothing can fill it in later, since the hub has no checkout to diff. Only pass this when no Claude credential is available; it costs one model call to leave the log answerable.").option("-m, --model <name>", "Model for the spec selection. Claude alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
|
|
8809
8809
|
const cwd = resolveCwd(opts.cwd);
|
|
8810
8810
|
const project = resolveProject(opts);
|
|
8811
8811
|
const hub = connect(opts);
|
|
@@ -8813,7 +8813,7 @@ const deployRecord = new Command("record").description("Tell the hub what a depl
|
|
|
8813
8813
|
const runUrl = githubRunUrl();
|
|
8814
8814
|
const diff = previous === null ? null : await diffOrNull(previous, opts.sha, cwd);
|
|
8815
8815
|
const changedPaths = diff ? capDeployPaths(diff.map((f) => f.path)) : null;
|
|
8816
|
-
const selection = opts.
|
|
8816
|
+
const selection = opts.selectSpecs !== false && previous !== null && diff !== null ? await selectionForDeploy(diff, previous, opts.sha, cwd, opts.model) : void 0;
|
|
8817
8817
|
const entry = await hub.recordDeploy(project, opts.profile, {
|
|
8818
8818
|
sha: opts.sha,
|
|
8819
8819
|
previousSha: previous,
|
|
@@ -8827,8 +8827,12 @@ const deployRecord = new Command("record").description("Tell the hub what a depl
|
|
|
8827
8827
|
meta("profile", opts.profile);
|
|
8828
8828
|
meta("previous", previous ? previous.slice(0, 12) : "(none)");
|
|
8829
8829
|
meta("changed paths", changedPaths === null ? "(not reported)" : String(changedPaths.length));
|
|
8830
|
-
if (opts.
|
|
8831
|
-
if (entry.gapBefore) warn("this deploy does not chain onto the log head, so a gap is recorded — specs whose baseline sits behind it report '
|
|
8830
|
+
if (opts.selectSpecs !== false) meta("selection", describeSelection(selection, diff !== null));
|
|
8831
|
+
if (entry.gapBefore) warn("this deploy does not chain onto the log head, so a gap is recorded — specs whose baseline sits behind it report 'unanswerable' rather than 'verified'");
|
|
8832
|
+
if (selection && !entry.hasSelection) {
|
|
8833
|
+
error("the hub recorded this deploy but could not store its spec selection — the range answers 'unanswerable' from here on, and nothing fills it in later. Re-record this deploy.");
|
|
8834
|
+
process.exit(1);
|
|
8835
|
+
}
|
|
8832
8836
|
info(`recorded deploy #${entry.index}`);
|
|
8833
8837
|
}));
|
|
8834
8838
|
/**
|
|
@@ -8866,7 +8870,7 @@ async function selectionForDeploy(changed, previous, sha, cwd, model) {
|
|
|
8866
8870
|
/**
|
|
8867
8871
|
* The two-dot diff for this deploy's range, or `null` when git can't produce
|
|
8868
8872
|
* one (a shallow checkout that never fetched `previous`, a rolled-back sha
|
|
8869
|
-
* that isn't local). Shared by `changedPaths` and
|
|
8873
|
+
* that isn't local). Shared by `changedPaths` and the selection's input, so a
|
|
8870
8874
|
* diff failure skips both rather than each attempting — and separately
|
|
8871
8875
|
* failing — its own git call.
|
|
8872
8876
|
*/
|
|
@@ -8878,7 +8882,7 @@ async function diffOrNull(previous, sha, cwd) {
|
|
|
8878
8882
|
return null;
|
|
8879
8883
|
}
|
|
8880
8884
|
}
|
|
8881
|
-
/** The
|
|
8885
|
+
/** The selection summary line: verdict counts, or why there isn't one. */
|
|
8882
8886
|
function describeSelection(selection, diffAvailable) {
|
|
8883
8887
|
if (!selection) return diffAvailable ? "(skipped — see warning above)" : "(skipped — no diff to select against)";
|
|
8884
8888
|
const values = Object.values(selection);
|
|
@@ -15429,6 +15433,10 @@ async function runAudit(specPath, opts) {
|
|
|
15429
15433
|
error("--only-hub-audit-needed and an explicit spec id cannot be combined; it only applies to a full sweep");
|
|
15430
15434
|
process.exit(2);
|
|
15431
15435
|
}
|
|
15436
|
+
if (opts.onlyHubAuditNeeded && opts.onlyAffectedBy) {
|
|
15437
|
+
error("--only-hub-audit-needed and --only-affected-by cannot be combined: a spec the hub says needs auditing that the diff drops is never audited, so it stays due forever. Pick the one that matches the job — the hub answer after a deploy, the diff on a pull request.");
|
|
15438
|
+
process.exit(2);
|
|
15439
|
+
}
|
|
15432
15440
|
let hub = null;
|
|
15433
15441
|
let hubProject = null;
|
|
15434
15442
|
let holder = null;
|
|
@@ -15445,7 +15453,7 @@ async function runAudit(specPath, opts) {
|
|
|
15445
15453
|
});
|
|
15446
15454
|
}
|
|
15447
15455
|
let targets = await collectTargets(specPath, cwd);
|
|
15448
|
-
if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
|
|
15456
|
+
if (targets.length === 0) exitWithNoSpecs(format, "noSpecsFound", "no test specs found under .ccqa/features/");
|
|
15449
15457
|
if (format === "text") {
|
|
15450
15458
|
header("audit", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
|
|
15451
15459
|
if (opts.cwd) meta("cwd", cwd);
|
|
@@ -15462,12 +15470,12 @@ async function runAudit(specPath, opts) {
|
|
|
15462
15470
|
meta("hub", selection.summary);
|
|
15463
15471
|
meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
|
|
15464
15472
|
}
|
|
15465
|
-
if (targets.length === 0) exitWithNoSpecs(format, "every spec has been audited since the last deploy that reached it");
|
|
15473
|
+
if (targets.length === 0) exitWithNoSpecs(format, "allCurrent", "every spec has been audited since the last deploy that reached it");
|
|
15466
15474
|
holder = randomUUID();
|
|
15467
15475
|
const claimed = await claimSpecs(hub, hubProject, opts.hubProfile, targets, holder);
|
|
15468
15476
|
if (claimed.length < targets.length && format === "text") meta("held-elsewhere", `${targets.length - claimed.length} spec(s) another job is auditing`);
|
|
15469
15477
|
targets = claimed;
|
|
15470
|
-
if (targets.length === 0) exitWithNoSpecs(format, "every spec that needs auditing is already being audited by another job");
|
|
15478
|
+
if (targets.length === 0) exitWithNoSpecs(format, "allHeld", "every spec that needs auditing is already being audited by another job");
|
|
15471
15479
|
}
|
|
15472
15480
|
let baseRef = null;
|
|
15473
15481
|
if (opts.onlyAffectedBy) {
|
|
@@ -15481,7 +15489,7 @@ async function runAudit(specPath, opts) {
|
|
|
15481
15489
|
targets = selection.specs;
|
|
15482
15490
|
baseRef = selection.base.ref;
|
|
15483
15491
|
if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
|
|
15484
|
-
if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
|
|
15492
|
+
if (targets.length === 0) exitWithNoSpecs(format, "noDiffIntersection", "no specs intersect the changed file set; nothing to check");
|
|
15485
15493
|
}
|
|
15486
15494
|
const blocks = await loadAvailableBlocks(cwd);
|
|
15487
15495
|
try {
|
|
@@ -15592,10 +15600,14 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
|
|
|
15592
15600
|
throw err;
|
|
15593
15601
|
}
|
|
15594
15602
|
}
|
|
15595
|
-
function exitWithNoSpecs(format, message) {
|
|
15603
|
+
function exitWithNoSpecs(format, reason, message) {
|
|
15596
15604
|
reportCost();
|
|
15597
|
-
if (format === "json") process.stdout.write(`${JSON.stringify({
|
|
15605
|
+
if (format === "json") process.stdout.write(`${JSON.stringify({
|
|
15606
|
+
specs: [],
|
|
15607
|
+
skipped: reason
|
|
15608
|
+
}, null, 2)}\n`);
|
|
15598
15609
|
else if (format === "text") info(message);
|
|
15610
|
+
else if (format === "github" && reason === "noSpecsFound") process.stdout.write(`::warning::${message}\n`);
|
|
15599
15611
|
process.exit(0);
|
|
15600
15612
|
}
|
|
15601
15613
|
async function collectTargets(specPath, cwd) {
|
|
@@ -17296,27 +17308,33 @@ function createRecordDeployHandler(storage) {
|
|
|
17296
17308
|
...ref ? { ref } : {},
|
|
17297
17309
|
...runUrl ? { runUrl } : {},
|
|
17298
17310
|
changedPaths: changedPaths ?? null,
|
|
17299
|
-
hasSelection:
|
|
17311
|
+
hasSelection: false
|
|
17312
|
+
});
|
|
17313
|
+
const folded = selection !== void 0 && await foldIntoTouchIndex(storage, project, profile, entry, selection);
|
|
17314
|
+
if (folded) await storage.deploys.confirmSelection(project, profile, entry.index);
|
|
17315
|
+
sendJson(ctx.res, 201, {
|
|
17316
|
+
...entry,
|
|
17317
|
+
hasSelection: folded === true
|
|
17300
17318
|
});
|
|
17301
|
-
if (selection !== void 0) await foldIntoTouchIndex(storage, project, profile, entry, selection);
|
|
17302
|
-
sendJson(ctx.res, 201, entry);
|
|
17303
17319
|
};
|
|
17304
17320
|
}
|
|
17305
17321
|
/**
|
|
17306
17322
|
* Record what this deploy's selection decided, so a later read can answer each
|
|
17307
17323
|
* spec's own range with two integer comparisons.
|
|
17308
17324
|
*
|
|
17309
|
-
*
|
|
17310
|
-
*
|
|
17311
|
-
*
|
|
17312
|
-
*
|
|
17313
|
-
*
|
|
17325
|
+
* It does not fail the request: the log is the record of what shipped and has
|
|
17326
|
+
* to land even if the fold cannot. What a lost fold must not do is *look* like
|
|
17327
|
+
* a recorded one — so the caller only marks the entry `hasSelection` when this
|
|
17328
|
+
* returns true, and the range reads as unresolved otherwise. The client is told
|
|
17329
|
+
* by the entry it gets back.
|
|
17314
17330
|
*/
|
|
17315
17331
|
async function foldIntoTouchIndex(storage, project, profile, entry, selection) {
|
|
17316
17332
|
try {
|
|
17317
17333
|
await storage.deploys.updateTouchIndex(project, profile, (current) => foldTouchIndex(current, entry, selection));
|
|
17334
|
+
return true;
|
|
17318
17335
|
} catch (err) {
|
|
17319
17336
|
console.error(`hub: touch-index fold failed for deploy "${entry.sha}" of "${project}/${profile}": ${errMsg(err)}`);
|
|
17337
|
+
return false;
|
|
17320
17338
|
}
|
|
17321
17339
|
}
|
|
17322
17340
|
/** GET /api/v1/projects/:project/deploys?profile=&limit= — the retained log, oldest first. */
|
|
@@ -23434,6 +23452,18 @@ function createFileDeployStore(root) {
|
|
|
23434
23452
|
const log = await updateJson(deployLogPath(root, project, profile), (current) => appendDeploy(current, input));
|
|
23435
23453
|
return log.entries[log.entries.length - 1];
|
|
23436
23454
|
},
|
|
23455
|
+
async confirmSelection(project, profile, index) {
|
|
23456
|
+
await updateJson(deployLogPath(root, project, profile), (current) => {
|
|
23457
|
+
const log = current ?? emptyDeployLog();
|
|
23458
|
+
return {
|
|
23459
|
+
...log,
|
|
23460
|
+
entries: log.entries.map((e) => e.index === index ? {
|
|
23461
|
+
...e,
|
|
23462
|
+
hasSelection: true
|
|
23463
|
+
} : e)
|
|
23464
|
+
};
|
|
23465
|
+
});
|
|
23466
|
+
},
|
|
23437
23467
|
getLog: readLog,
|
|
23438
23468
|
async head(project, profile) {
|
|
23439
23469
|
const { entries } = await readLog(project, profile);
|
package/dist/package.json
CHANGED