ccqa 0.11.0 → 0.12.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 +272 -44
- package/dist/hub-client/index.d.mts +11 -0
- package/dist/hub-client/index.mjs +1 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -2630,6 +2630,7 @@ const ReportSpecResultSchema = z.object({
|
|
|
2630
2630
|
});
|
|
2631
2631
|
const RunReportDataSchema = z.object({
|
|
2632
2632
|
schemaVersion: z.literal(1),
|
|
2633
|
+
kind: z.enum(["run", "drift"]).default("run"),
|
|
2633
2634
|
createdAt: z.string(),
|
|
2634
2635
|
runId: z.string().nullable(),
|
|
2635
2636
|
git: z.object({
|
|
@@ -3242,6 +3243,25 @@ function resolveHubContext(opts) {
|
|
|
3242
3243
|
project: resolveProjectOrThrow(opts.project, opts.cwd ?? process.cwd())
|
|
3243
3244
|
};
|
|
3244
3245
|
}
|
|
3246
|
+
/**
|
|
3247
|
+
* Wrap a subcommand action so a `HubApiError` (hub request failed, e.g. a
|
|
3248
|
+
* 503 when the hub has no encryption key configured) prints a clean message
|
|
3249
|
+
* and exits 2, instead of surfacing as an unhandled rejection with a stack
|
|
3250
|
+
* trace.
|
|
3251
|
+
*/
|
|
3252
|
+
function withHubErrors(fn) {
|
|
3253
|
+
return async (...args) => {
|
|
3254
|
+
try {
|
|
3255
|
+
await fn(...args);
|
|
3256
|
+
} catch (err) {
|
|
3257
|
+
if (err instanceof HubApiError) {
|
|
3258
|
+
error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
|
|
3259
|
+
process.exit(2);
|
|
3260
|
+
}
|
|
3261
|
+
throw err;
|
|
3262
|
+
}
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3245
3265
|
//#endregion
|
|
3246
3266
|
//#region src/cli/options.ts
|
|
3247
3267
|
/**
|
|
@@ -3856,6 +3876,33 @@ function resolvePromptLocalPath(name, cwd) {
|
|
|
3856
3876
|
return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
|
|
3857
3877
|
}
|
|
3858
3878
|
//#endregion
|
|
3879
|
+
//#region src/cli/git-branch.ts
|
|
3880
|
+
/** Best-effort current branch: CI env vars first, then git, else null. */
|
|
3881
|
+
async function detectBranch(cwd) {
|
|
3882
|
+
const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
|
|
3883
|
+
if (fromEnv) return fromEnv;
|
|
3884
|
+
try {
|
|
3885
|
+
const { stdout } = await execFileP("git", [
|
|
3886
|
+
"rev-parse",
|
|
3887
|
+
"--abbrev-ref",
|
|
3888
|
+
"HEAD"
|
|
3889
|
+
], { cwd });
|
|
3890
|
+
const branch = stdout.trim();
|
|
3891
|
+
return branch && branch !== "HEAD" ? branch : null;
|
|
3892
|
+
} catch {
|
|
3893
|
+
return null;
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
/** Best-effort current commit SHA, or null (e.g. not a git repo). */
|
|
3897
|
+
async function getGitHead(cwd) {
|
|
3898
|
+
try {
|
|
3899
|
+
const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
|
|
3900
|
+
return stdout.trim() || null;
|
|
3901
|
+
} catch {
|
|
3902
|
+
return null;
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
//#endregion
|
|
3859
3906
|
//#region src/cli/hub.ts
|
|
3860
3907
|
/**
|
|
3861
3908
|
* `ccqa hub` — the client side of the ccqa hub (a results/secret control
|
|
@@ -3904,25 +3951,6 @@ async function readStdin() {
|
|
|
3904
3951
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3905
3952
|
return Buffer.concat(chunks).toString("utf8");
|
|
3906
3953
|
}
|
|
3907
|
-
/**
|
|
3908
|
-
* Wrap a subcommand action so a `HubApiError` (hub request failed, e.g. a
|
|
3909
|
-
* 503 when the hub has no encryption key configured) prints a clean message
|
|
3910
|
-
* and exits 2, instead of surfacing as an unhandled rejection with a stack
|
|
3911
|
-
* trace.
|
|
3912
|
-
*/
|
|
3913
|
-
function withHubErrors(fn) {
|
|
3914
|
-
return async (...args) => {
|
|
3915
|
-
try {
|
|
3916
|
-
await fn(...args);
|
|
3917
|
-
} catch (err) {
|
|
3918
|
-
if (err instanceof HubApiError) {
|
|
3919
|
-
error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
|
|
3920
|
-
process.exit(2);
|
|
3921
|
-
}
|
|
3922
|
-
throw err;
|
|
3923
|
-
}
|
|
3924
|
-
};
|
|
3925
|
-
}
|
|
3926
3954
|
const sessionPush = new Command("push").description("Upload a locally-saved browser session (.ccqa/sessions/<profile>/<name>.json) to the hub, so it's available for `ccqa run` to fetch at run time. Encrypted at rest on the hub.").argument("<name>", "Session name to upload (resolves to .ccqa/sessions/<profile>/<name>.json)").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(withHubErrors(async (rawName, opts) => {
|
|
3927
3955
|
const name = validateSessionName(rawName);
|
|
3928
3956
|
const cwd = resolveCwd(opts.cwd);
|
|
@@ -4084,22 +4112,6 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
4084
4112
|
info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
|
|
4085
4113
|
}));
|
|
4086
4114
|
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
|
|
4087
|
-
/** Best-effort current branch: CI env vars first, then git, else null. */
|
|
4088
|
-
async function detectBranch(cwd) {
|
|
4089
|
-
const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
|
|
4090
|
-
if (fromEnv) return fromEnv;
|
|
4091
|
-
try {
|
|
4092
|
-
const { stdout } = await execFileP("git", [
|
|
4093
|
-
"rev-parse",
|
|
4094
|
-
"--abbrev-ref",
|
|
4095
|
-
"HEAD"
|
|
4096
|
-
], { cwd });
|
|
4097
|
-
const branch = stdout.trim();
|
|
4098
|
-
return branch && branch !== "HEAD" ? branch : null;
|
|
4099
|
-
} catch {
|
|
4100
|
-
return null;
|
|
4101
|
-
}
|
|
4102
|
-
}
|
|
4103
4115
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
4104
4116
|
function isStorageStateShape(state) {
|
|
4105
4117
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -5573,6 +5585,7 @@ async function writeUnifiedReport(args) {
|
|
|
5573
5585
|
const { reportDir, results, diff, baseRef, customPromptVersion, opts } = args;
|
|
5574
5586
|
const data = {
|
|
5575
5587
|
schemaVersion: 1,
|
|
5588
|
+
kind: "run",
|
|
5576
5589
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5577
5590
|
runId: process.env["GITHUB_RUN_ID"] ?? null,
|
|
5578
5591
|
git: {
|
|
@@ -9148,6 +9161,55 @@ function determineExitCode(results, threshold) {
|
|
|
9148
9161
|
}
|
|
9149
9162
|
return 0;
|
|
9150
9163
|
}
|
|
9164
|
+
/**
|
|
9165
|
+
* Spec-level status under the given threshold, mirroring determineExitCode's
|
|
9166
|
+
* per-issue logic (exit-code.ts) but scoped to a single SpecResult.
|
|
9167
|
+
*/
|
|
9168
|
+
function specStatus(result, threshold) {
|
|
9169
|
+
if (result.error) return "failed";
|
|
9170
|
+
for (const issue of result.issues) {
|
|
9171
|
+
if (issue.severity === "ERROR") return "failed";
|
|
9172
|
+
if (threshold === "warn" && issue.severity === "WARN") return "failed";
|
|
9173
|
+
}
|
|
9174
|
+
return "passed";
|
|
9175
|
+
}
|
|
9176
|
+
/**
|
|
9177
|
+
* Adapts `ccqa drift` results into the shared RunReportData shape so they can
|
|
9178
|
+
* be pushed to the hub (`ccqa drift --push`) and rendered by the same report
|
|
9179
|
+
* UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
|
|
9180
|
+
* evidence, liveRun, ...) don't apply to a drift audit and are always null.
|
|
9181
|
+
*/
|
|
9182
|
+
function driftResultsToReport(results, meta) {
|
|
9183
|
+
const specResults = results.map((result) => ({
|
|
9184
|
+
feature: result.target.featureName,
|
|
9185
|
+
spec: result.target.specName,
|
|
9186
|
+
title: null,
|
|
9187
|
+
status: specStatus(result, meta.threshold),
|
|
9188
|
+
testCounts: null,
|
|
9189
|
+
durationMs: null,
|
|
9190
|
+
assertions: null,
|
|
9191
|
+
analysis: null,
|
|
9192
|
+
analysisSkipped: null,
|
|
9193
|
+
driftIssues: result.issues,
|
|
9194
|
+
failureLogExcerpt: null,
|
|
9195
|
+
diffExcerpt: null,
|
|
9196
|
+
specYaml: null,
|
|
9197
|
+
evidence: null,
|
|
9198
|
+
liveRun: null
|
|
9199
|
+
}));
|
|
9200
|
+
return {
|
|
9201
|
+
schemaVersion: 1,
|
|
9202
|
+
kind: "drift",
|
|
9203
|
+
createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
9204
|
+
runId: meta.runId ?? null,
|
|
9205
|
+
git: meta.git,
|
|
9206
|
+
model: meta.model ?? null,
|
|
9207
|
+
language: meta.language ?? null,
|
|
9208
|
+
promptVersion: meta.promptVersion ?? "1",
|
|
9209
|
+
customPromptVersion: null,
|
|
9210
|
+
results: specResults
|
|
9211
|
+
};
|
|
9212
|
+
}
|
|
9151
9213
|
//#endregion
|
|
9152
9214
|
//#region src/drift/route-new-files.ts
|
|
9153
9215
|
/**
|
|
@@ -9263,7 +9325,7 @@ Return the spec keys that might be affected by any of the new files. Conservativ
|
|
|
9263
9325
|
//#endregion
|
|
9264
9326
|
//#region src/cli/drift.ts
|
|
9265
9327
|
const DEFAULT_CONCURRENCY = 3;
|
|
9266
|
-
const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.")).action(async (specPath, opts) => {
|
|
9328
|
+
const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption)).action(async (specPath, opts) => {
|
|
9267
9329
|
const format = parseFormat(opts.format);
|
|
9268
9330
|
const threshold = parseSeverity(opts.severity);
|
|
9269
9331
|
const concurrency = parseConcurrency(opts.concurrency);
|
|
@@ -9279,6 +9341,7 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
|
|
|
9279
9341
|
header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
|
|
9280
9342
|
if (opts.cwd) meta("cwd", cwd);
|
|
9281
9343
|
}
|
|
9344
|
+
const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
|
|
9282
9345
|
if (opts.changed) {
|
|
9283
9346
|
const total = targets.length;
|
|
9284
9347
|
targets = await filterByChanged({
|
|
@@ -9304,8 +9367,69 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
|
|
|
9304
9367
|
}
|
|
9305
9368
|
});
|
|
9306
9369
|
process.stdout.write(renderDrift(results, format, cwd));
|
|
9370
|
+
if (opts.push) await pushDriftResults({
|
|
9371
|
+
results,
|
|
9372
|
+
threshold,
|
|
9373
|
+
cwd,
|
|
9374
|
+
opts,
|
|
9375
|
+
format,
|
|
9376
|
+
baseRef
|
|
9377
|
+
});
|
|
9307
9378
|
process.exit(determineExitCode(results, threshold));
|
|
9308
9379
|
});
|
|
9380
|
+
/**
|
|
9381
|
+
* Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
|
|
9382
|
+
* shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
|
|
9383
|
+
* hub connection warns and returns rather than failing the command (`--push`
|
|
9384
|
+
* never changes drift's own exit code).
|
|
9385
|
+
*
|
|
9386
|
+
* `resolveHub` is injectable so tests can supply a fake `HubClient` without
|
|
9387
|
+
* a real hub connection; it defaults to the real flag/env resolution.
|
|
9388
|
+
*/
|
|
9389
|
+
async function pushDriftResults(args, resolveHub = resolveHubClient) {
|
|
9390
|
+
const { results, threshold, cwd, opts, format, baseRef } = args;
|
|
9391
|
+
const hub = resolveHub(opts);
|
|
9392
|
+
if (!hub) {
|
|
9393
|
+
warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
|
|
9394
|
+
return;
|
|
9395
|
+
}
|
|
9396
|
+
try {
|
|
9397
|
+
const project = resolveProject({
|
|
9398
|
+
project: opts.project,
|
|
9399
|
+
cwd
|
|
9400
|
+
});
|
|
9401
|
+
const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
9402
|
+
const report = driftResultsToReport(results, {
|
|
9403
|
+
threshold,
|
|
9404
|
+
git: {
|
|
9405
|
+
head,
|
|
9406
|
+
base: baseRef ?? null
|
|
9407
|
+
}
|
|
9408
|
+
});
|
|
9409
|
+
const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
|
|
9410
|
+
try {
|
|
9411
|
+
await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
|
|
9412
|
+
const archive = await packDirToTarGz(dir);
|
|
9413
|
+
const run = await hub.pushRun(archive, {
|
|
9414
|
+
project,
|
|
9415
|
+
...branch ? { branch } : {},
|
|
9416
|
+
kind: "drift"
|
|
9417
|
+
});
|
|
9418
|
+
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
|
|
9419
|
+
} finally {
|
|
9420
|
+
await rm(dir, {
|
|
9421
|
+
recursive: true,
|
|
9422
|
+
force: true
|
|
9423
|
+
});
|
|
9424
|
+
}
|
|
9425
|
+
} catch (err) {
|
|
9426
|
+
if (err instanceof HubApiError) {
|
|
9427
|
+
error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
|
|
9428
|
+
process.exit(2);
|
|
9429
|
+
}
|
|
9430
|
+
throw err;
|
|
9431
|
+
}
|
|
9432
|
+
}
|
|
9309
9433
|
function exitWithNoSpecs(format, message) {
|
|
9310
9434
|
if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
|
|
9311
9435
|
else if (format === "text") info(message);
|
|
@@ -10073,6 +10197,13 @@ z.object({
|
|
|
10073
10197
|
profile: z.string().nullable(),
|
|
10074
10198
|
branch: z.string().nullable(),
|
|
10075
10199
|
status: RunStatusSchema,
|
|
10200
|
+
kind: z.enum(["run", "drift"]).default("run"),
|
|
10201
|
+
drift: z.object({
|
|
10202
|
+
issues: z.number(),
|
|
10203
|
+
errors: z.number(),
|
|
10204
|
+
warnings: z.number(),
|
|
10205
|
+
specsWithIssues: z.number()
|
|
10206
|
+
}).nullable().default(null),
|
|
10076
10207
|
specs: z.object({
|
|
10077
10208
|
total: z.number(),
|
|
10078
10209
|
passed: z.number(),
|
|
@@ -10207,6 +10338,9 @@ function createPushRunHandler(config) {
|
|
|
10207
10338
|
const branch = requireBranch(ctx.url.searchParams.get("branch"));
|
|
10208
10339
|
const profileRaw = ctx.url.searchParams.get("profile");
|
|
10209
10340
|
const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
|
|
10341
|
+
const kindRaw = ctx.url.searchParams.get("kind");
|
|
10342
|
+
if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
|
|
10343
|
+
const kind = kindRaw ?? "run";
|
|
10210
10344
|
const body = await readBody(ctx.req, maxPushBytes);
|
|
10211
10345
|
const dir = await mkdtemp(join(tmpdir(), "ccqa-hub-push-"));
|
|
10212
10346
|
try {
|
|
@@ -10224,15 +10358,19 @@ function createPushRunHandler(config) {
|
|
|
10224
10358
|
const parsed = RunReportDataSchema.safeParse(reportJson);
|
|
10225
10359
|
if (!parsed.success) throw new HttpError(400, "invalid_report", `report.json is not a valid report: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
|
|
10226
10360
|
const report = parsed.data;
|
|
10361
|
+
if (report.kind !== kind) throw new HttpError(400, "kind_mismatch", `?kind=${kind} does not match report.json's kind ("${report.kind}") — push with the matching ?kind= query param`);
|
|
10227
10362
|
const total = report.results.length;
|
|
10228
10363
|
const failed = report.results.filter((r) => r.status === "failed").length;
|
|
10229
10364
|
const status = failed > 0 ? "failed" : "passed";
|
|
10365
|
+
const drift = kind === "drift" ? summarizeDrift(report.results) : null;
|
|
10230
10366
|
const run = {
|
|
10231
10367
|
id: randomUUID(),
|
|
10232
10368
|
project,
|
|
10233
10369
|
profile,
|
|
10234
10370
|
branch,
|
|
10235
10371
|
status,
|
|
10372
|
+
kind,
|
|
10373
|
+
drift,
|
|
10236
10374
|
specs: {
|
|
10237
10375
|
total,
|
|
10238
10376
|
passed: total - failed,
|
|
@@ -10317,6 +10455,28 @@ async function getRunOr404(storage, id) {
|
|
|
10317
10455
|
if (!run) throw new HttpError(404, "not_found", `run "${id}" not found`);
|
|
10318
10456
|
return run;
|
|
10319
10457
|
}
|
|
10458
|
+
/** Tally `driftIssues` across all specs into the `Run.drift` summary counters. */
|
|
10459
|
+
function summarizeDrift(results) {
|
|
10460
|
+
let issues = 0;
|
|
10461
|
+
let errors = 0;
|
|
10462
|
+
let warnings = 0;
|
|
10463
|
+
let specsWithIssues = 0;
|
|
10464
|
+
for (const r of results) {
|
|
10465
|
+
const driftIssues = r.driftIssues ?? [];
|
|
10466
|
+
if (driftIssues.length > 0) specsWithIssues++;
|
|
10467
|
+
for (const issue of driftIssues) {
|
|
10468
|
+
issues++;
|
|
10469
|
+
if (issue.severity === "ERROR") errors++;
|
|
10470
|
+
else if (issue.severity === "WARN") warnings++;
|
|
10471
|
+
}
|
|
10472
|
+
}
|
|
10473
|
+
return {
|
|
10474
|
+
issues,
|
|
10475
|
+
errors,
|
|
10476
|
+
warnings,
|
|
10477
|
+
specsWithIssues
|
|
10478
|
+
};
|
|
10479
|
+
}
|
|
10320
10480
|
/**
|
|
10321
10481
|
* A branch is a free-form label (e.g. `feature/foo`), stored verbatim and
|
|
10322
10482
|
* never used to build a filesystem path, so `/` is allowed — only length is
|
|
@@ -11095,7 +11255,7 @@ const HTML_BODY = `
|
|
|
11095
11255
|
|
|
11096
11256
|
<!-- Triage first: grading + learning is the most important action, so it
|
|
11097
11257
|
sits above the spec list rather than being buried below it. -->
|
|
11098
|
-
<div class="triage-head">
|
|
11258
|
+
<div class="triage-head" id="triage-head">
|
|
11099
11259
|
<h3 style="font-size:14px" data-i18n="detail.triage">Triage</h3>
|
|
11100
11260
|
<span class="triage-summary" id="triage-summary"></span>
|
|
11101
11261
|
</div>
|
|
@@ -11430,7 +11590,16 @@ const CSS = `
|
|
|
11430
11590
|
.badge-det { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
11431
11591
|
.badge.drift-warn { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
11432
11592
|
.badge.drift-warn .d { background: var(--amber); }
|
|
11593
|
+
.badge-drift { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
|
|
11433
11594
|
.chip { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 6px; background: var(--surface-3); border: 1px solid var(--border); color: var(--fg-dim); font-size: 12px; font-family: var(--mono); }
|
|
11595
|
+
/* Below .chip in source order so these override its background/border/color
|
|
11596
|
+
when combined as class="chip drift-*-chip" (same specificity — source
|
|
11597
|
+
order decides). */
|
|
11598
|
+
.chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); margin-left: 6px; }
|
|
11599
|
+
.chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); margin-left: 6px; }
|
|
11600
|
+
.chip.drift-errors-chip { color: var(--fail); background: var(--fail-bg); border-color: var(--fail-border); }
|
|
11601
|
+
.drift-meta-box { display: flex; flex-direction: column; gap: 4px; }
|
|
11602
|
+
.drift-meta-chips { display: flex; gap: 6px; }
|
|
11434
11603
|
.specs { display: inline-flex; align-items: center; gap: 9px; }
|
|
11435
11604
|
.meter { width: 54px; height: 6px; border-radius: 3px; background: var(--fail-bg); overflow: hidden; }
|
|
11436
11605
|
.meter i { display: block; height: 100%; background: var(--pass); }
|
|
@@ -11519,6 +11688,7 @@ const CSS = `
|
|
|
11519
11688
|
.drift-step { font-family: var(--mono); font-size: 11px; color: var(--muted-2); }
|
|
11520
11689
|
.drift-msg { margin-top: 4px; color: var(--fg-dim); }
|
|
11521
11690
|
.drift-detail { margin-top: 3px; color: var(--muted); font-size: 12px; }
|
|
11691
|
+
.drift-clean { color: var(--pass); font-size: 13px; }
|
|
11522
11692
|
|
|
11523
11693
|
/* triage grading — an explicit question + a segmented single-select, framed
|
|
11524
11694
|
as an action ("tell us the real cause"), not a data readout. */
|
|
@@ -11724,12 +11894,17 @@ const CLIENT_JS = `
|
|
|
11724
11894
|
"detail.triage": "Triage",
|
|
11725
11895
|
"meta.branch": "Branch", "meta.specs": "Specs", "meta.prompt": "Prompt",
|
|
11726
11896
|
"meta.created": "Created", "meta.passed": "passed", "meta.profile": "Profile",
|
|
11897
|
+
"meta.drift": "Drift",
|
|
11727
11898
|
"rec.title": "Recommendation",
|
|
11728
11899
|
"acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
|
|
11729
11900
|
"acc.assertions": "Assertions", "acc.drift": "Drift audit",
|
|
11730
11901
|
"acc.assertions.hint": "Test cases from the recorded spec run",
|
|
11731
11902
|
"spec.kind.live": "Live", "spec.kind.det": "Deterministic",
|
|
11732
11903
|
"spec.driftWarn": "Spec drift", "det.steps": "Steps",
|
|
11904
|
+
"kind.run": "Test run", "kind.drift": "Drift audit",
|
|
11905
|
+
"drift.summary.issues": "Issues", "drift.summary.errors": "Errors",
|
|
11906
|
+
"drift.summary.warnings": "Warnings", "drift.summary.specsWithIssues": "Specs with issues",
|
|
11907
|
+
"drift.clean": "No drift issues",
|
|
11733
11908
|
"grade.question": "What was the real cause?", "grade.predicted": "predicted",
|
|
11734
11909
|
"grade.ungraded": "ungraded", "grade.matches": "saved · matches",
|
|
11735
11910
|
"grade.corrected": "saved · corrected", "grade.saving": "saving…",
|
|
@@ -11785,12 +11960,17 @@ const CLIENT_JS = `
|
|
|
11785
11960
|
"detail.triage": "トリアージ",
|
|
11786
11961
|
"meta.branch": "ブランチ", "meta.specs": "スペック", "meta.prompt": "プロンプト",
|
|
11787
11962
|
"meta.created": "作成", "meta.passed": "合格", "meta.profile": "プロファイル",
|
|
11963
|
+
"meta.drift": "ドリフト",
|
|
11788
11964
|
"rec.title": "推奨対応",
|
|
11789
11965
|
"acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
|
|
11790
11966
|
"acc.assertions": "アサーション", "acc.drift": "ドリフト監査",
|
|
11791
11967
|
"acc.assertions.hint": "記録したスペック実行のテストケース",
|
|
11792
11968
|
"spec.kind.live": "ライブ", "spec.kind.det": "決定的",
|
|
11793
11969
|
"spec.driftWarn": "仕様ドリフト", "det.steps": "ステップ",
|
|
11970
|
+
"kind.run": "テスト実行", "kind.drift": "ドリフト監査",
|
|
11971
|
+
"drift.summary.issues": "問題数", "drift.summary.errors": "エラー",
|
|
11972
|
+
"drift.summary.warnings": "警告", "drift.summary.specsWithIssues": "問題のあるスペック",
|
|
11973
|
+
"drift.clean": "ドリフトの問題なし",
|
|
11794
11974
|
"grade.question": "実際の原因は何でしたか?", "grade.predicted": "予測",
|
|
11795
11975
|
"grade.ungraded": "未評価", "grade.matches": "保存済み · 一致",
|
|
11796
11976
|
"grade.corrected": "保存済み · 修正", "grade.saving": "保存中…",
|
|
@@ -12170,6 +12350,19 @@ const CLIENT_JS = `
|
|
|
12170
12350
|
runCell.appendChild(el("div", "runid", r.id.slice(0, 8)));
|
|
12171
12351
|
var sub = el("div", "subline");
|
|
12172
12352
|
sub.appendChild(ciBadge(r));
|
|
12353
|
+
if (r.kind === "drift") {
|
|
12354
|
+
sub.appendChild(el("span", "chip kind-chip", t("kind.drift")));
|
|
12355
|
+
if (r.drift) {
|
|
12356
|
+
if (r.drift.errors > 0) {
|
|
12357
|
+
sub.appendChild(el("span", "chip drift-errors-chip", t("drift.summary.errors") + " " + r.drift.errors));
|
|
12358
|
+
}
|
|
12359
|
+
if (r.drift.warnings > 0) {
|
|
12360
|
+
sub.appendChild(el("span", "chip drift-count-chip", t("drift.summary.warnings") + " " + r.drift.warnings));
|
|
12361
|
+
}
|
|
12362
|
+
}
|
|
12363
|
+
} else {
|
|
12364
|
+
sub.appendChild(el("span", "chip kind-chip", t("kind.run")));
|
|
12365
|
+
}
|
|
12173
12366
|
runCell.appendChild(sub);
|
|
12174
12367
|
tr.appendChild(runCell);
|
|
12175
12368
|
|
|
@@ -12245,7 +12438,29 @@ const CLIENT_JS = `
|
|
|
12245
12438
|
metaItem(t("meta.branch"), branchChip);
|
|
12246
12439
|
// Which environment the run executed against (recorded at push, display-only).
|
|
12247
12440
|
if (run.profile) metaItem(t("meta.profile"), el("span", "chip", run.profile));
|
|
12248
|
-
|
|
12441
|
+
if (run.kind === "drift" && run.drift) {
|
|
12442
|
+
// Drift runs have no live/deterministic spec pass count. Errors/warnings
|
|
12443
|
+
// are the actionable counts and get colored chips; issues/specsWithIssues
|
|
12444
|
+
// are supplementary totals shown as a muted line below.
|
|
12445
|
+
if (run.drift.errors === 0 && run.drift.warnings === 0) {
|
|
12446
|
+
metaItem(t("meta.drift"), el("div", "drift-clean", t("drift.clean")));
|
|
12447
|
+
} else {
|
|
12448
|
+
var driftBox = el("div", "drift-meta-box");
|
|
12449
|
+
var chips = el("div", "drift-meta-chips");
|
|
12450
|
+
if (run.drift.errors > 0) {
|
|
12451
|
+
chips.appendChild(el("span", "chip drift-errors-chip", t("drift.summary.errors") + " " + run.drift.errors));
|
|
12452
|
+
}
|
|
12453
|
+
if (run.drift.warnings > 0) {
|
|
12454
|
+
chips.appendChild(el("span", "chip drift-count-chip", t("drift.summary.warnings") + " " + run.drift.warnings));
|
|
12455
|
+
}
|
|
12456
|
+
driftBox.appendChild(chips);
|
|
12457
|
+
var driftSub = el("div", "muted", t("drift.summary.specsWithIssues") + " " + run.drift.specsWithIssues + " / " + t("drift.summary.issues") + " " + run.drift.issues);
|
|
12458
|
+
driftBox.appendChild(driftSub);
|
|
12459
|
+
metaItem(t("meta.drift"), driftBox);
|
|
12460
|
+
}
|
|
12461
|
+
} else {
|
|
12462
|
+
metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
|
|
12463
|
+
}
|
|
12249
12464
|
metaItem(t("meta.prompt"), run.promptVersion || "—");
|
|
12250
12465
|
metaItem(t("meta.created"), relTime(run.createdAt));
|
|
12251
12466
|
head.appendChild(meta);
|
|
@@ -12583,7 +12798,7 @@ const CLIENT_JS = `
|
|
|
12583
12798
|
return wrap;
|
|
12584
12799
|
}
|
|
12585
12800
|
|
|
12586
|
-
function renderSpecCard(runId, r, triageState) {
|
|
12801
|
+
function renderSpecCard(runId, r, triageState, isDrift) {
|
|
12587
12802
|
var card = el("div", "spec-card " + r.status); // .passed / .failed rail
|
|
12588
12803
|
var head = el("div", "spec-card-head");
|
|
12589
12804
|
var nameBlock = el("div");
|
|
@@ -12591,7 +12806,10 @@ const CLIENT_JS = `
|
|
|
12591
12806
|
nameBlock.appendChild(el("div", "slug", r.feature + " / " + r.spec));
|
|
12592
12807
|
head.appendChild(nameBlock);
|
|
12593
12808
|
head.appendChild(el("div", "spacer"));
|
|
12594
|
-
head.appendChild(
|
|
12809
|
+
head.appendChild(
|
|
12810
|
+
isDrift ? el("span", "badge badge-drift", t("kind.drift"))
|
|
12811
|
+
: (r.liveRun ? el("span", "badge-live", t("spec.kind.live"))
|
|
12812
|
+
: el("span", "badge-det", t("spec.kind.det"))));
|
|
12595
12813
|
head.appendChild(statusBadge(r.status));
|
|
12596
12814
|
var hasDriftError = r.driftIssues && r.driftIssues.some(function (d) { return d.severity === "ERROR"; });
|
|
12597
12815
|
if (hasDriftError) {
|
|
@@ -12605,6 +12823,11 @@ const CLIENT_JS = `
|
|
|
12605
12823
|
var body = el("div", "spec-card-body");
|
|
12606
12824
|
var any = false;
|
|
12607
12825
|
|
|
12826
|
+
if (isDrift && (!r.driftIssues || r.driftIssues.length === 0)) {
|
|
12827
|
+
body.appendChild(el("div", "drift-clean", t("drift.clean")));
|
|
12828
|
+
any = true;
|
|
12829
|
+
}
|
|
12830
|
+
|
|
12608
12831
|
if (r.status === "failed" && r.analysis) {
|
|
12609
12832
|
// Tier2: the verdict block (analysis) + the grading action, always shown.
|
|
12610
12833
|
body.appendChild(analysisSection(runId, r));
|
|
@@ -12651,10 +12874,10 @@ const CLIENT_JS = `
|
|
|
12651
12874
|
return card;
|
|
12652
12875
|
}
|
|
12653
12876
|
|
|
12654
|
-
function renderSpecCards(runId, results, triageState) {
|
|
12877
|
+
function renderSpecCards(runId, results, triageState, isDrift) {
|
|
12655
12878
|
var container = document.getElementById("spec-cards");
|
|
12656
12879
|
clear(container);
|
|
12657
|
-
results.forEach(function (r) { container.appendChild(renderSpecCard(runId, r, triageState)); });
|
|
12880
|
+
results.forEach(function (r) { container.appendChild(renderSpecCard(runId, r, triageState, isDrift)); });
|
|
12658
12881
|
document.getElementById("detail-spec-count").textContent = "· " + results.length;
|
|
12659
12882
|
}
|
|
12660
12883
|
|
|
@@ -12791,9 +13014,14 @@ const CLIENT_JS = `
|
|
|
12791
13014
|
// triage loads so the saved grades restore. Keeping these separate means
|
|
12792
13015
|
// a triage failure (or a throw while rendering cards) can't be mislabelled
|
|
12793
13016
|
// as the other, and neither escapes its own catch.
|
|
12794
|
-
|
|
13017
|
+
var isDrift = report.kind === "drift";
|
|
13018
|
+
document.getElementById("triage-head").hidden = isDrift;
|
|
13019
|
+
document.getElementById("matrix-card").hidden = isDrift;
|
|
13020
|
+
document.getElementById("triage-progress").hidden = isDrift;
|
|
13021
|
+
renderSpecCards(runId, report.results, { byKey: {} }, isDrift);
|
|
13022
|
+
if (isDrift) return; // drift runs have no triage: skip loadTriage entirely
|
|
12795
13023
|
loadTriage(runId, function (triageState) {
|
|
12796
|
-
renderSpecCards(runId, report.results, triageState);
|
|
13024
|
+
renderSpecCards(runId, report.results, triageState, isDrift);
|
|
12797
13025
|
});
|
|
12798
13026
|
}).catch(detailError("Error loading report"));
|
|
12799
13027
|
}
|
|
@@ -31,6 +31,16 @@ declare const RunSchema: z.ZodObject<{
|
|
|
31
31
|
passed: "passed";
|
|
32
32
|
failed: "failed";
|
|
33
33
|
}>;
|
|
34
|
+
kind: z.ZodDefault<z.ZodEnum<{
|
|
35
|
+
run: "run";
|
|
36
|
+
drift: "drift";
|
|
37
|
+
}>>;
|
|
38
|
+
drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
|
|
39
|
+
issues: z.ZodNumber;
|
|
40
|
+
errors: z.ZodNumber;
|
|
41
|
+
warnings: z.ZodNumber;
|
|
42
|
+
specsWithIssues: z.ZodNumber;
|
|
43
|
+
}, z.core.$strip>>>;
|
|
34
44
|
specs: z.ZodObject<{
|
|
35
45
|
total: z.ZodNumber;
|
|
36
46
|
passed: z.ZodNumber;
|
|
@@ -197,6 +207,7 @@ interface HubClient {
|
|
|
197
207
|
project: string;
|
|
198
208
|
branch?: string;
|
|
199
209
|
profile?: string;
|
|
210
|
+
kind?: "run" | "drift";
|
|
200
211
|
}): Promise<Run>;
|
|
201
212
|
listRuns(q?: {
|
|
202
213
|
project?: string;
|
|
@@ -96,6 +96,7 @@ function createHubClient(opts) {
|
|
|
96
96
|
const params = new URLSearchParams({ project: meta.project });
|
|
97
97
|
if (meta.branch) params.set("branch", meta.branch);
|
|
98
98
|
if (meta.profile) params.set("profile", meta.profile);
|
|
99
|
+
if (meta.kind) params.set("kind", meta.kind);
|
|
99
100
|
return json(`/api/v1/runs?${params}`, {
|
|
100
101
|
method: "POST",
|
|
101
102
|
headers: { "Content-Type": "application/gzip" },
|
package/dist/package.json
CHANGED