ccqa 1.3.1 → 1.4.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 +4 -3
- package/dist/bin/ccqa.mjs +774 -362
- package/dist/hub-client/index.d.mts +41 -0
- package/dist/hub-client/index.mjs +10 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -11,10 +11,10 @@ import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolv
|
|
|
11
11
|
import { parse, stringify } from "yaml";
|
|
12
12
|
import { ZodError, z } from "zod";
|
|
13
13
|
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
14
|
-
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
+
import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
15
15
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
|
-
import { promisify } from "node:util";
|
|
17
16
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
18
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
19
19
|
import { createInterface } from "node:readline";
|
|
20
20
|
import { createInterface as createInterface$1 } from "node:readline/promises";
|
|
@@ -1287,7 +1287,7 @@ function warnOnceIfNativeBinaryMissing() {
|
|
|
1287
1287
|
if (missing) warn(missingNativeBinaryMessage(missing));
|
|
1288
1288
|
}
|
|
1289
1289
|
async function invokeClaudeStreaming(options, onEvent) {
|
|
1290
|
-
const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, relaxAbConstraints = false } = options;
|
|
1290
|
+
const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, mcpServers, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, relaxAbConstraints = false } = options;
|
|
1291
1291
|
const resolvedModel = resolveModel(model);
|
|
1292
1292
|
const hasEndpointEnv = Object.keys(resolveEndpointEnv()).length > 0;
|
|
1293
1293
|
const mergedEnv = env || hasEndpointEnv ? {
|
|
@@ -1309,6 +1309,7 @@ async function invokeClaudeStreaming(options, onEvent) {
|
|
|
1309
1309
|
...resolvedModel ? { model: resolvedModel } : {},
|
|
1310
1310
|
...cwd ? { cwd } : {},
|
|
1311
1311
|
...mergedEnv ? { env: mergedEnv } : {},
|
|
1312
|
+
...mcpServers ? { mcpServers } : {},
|
|
1312
1313
|
...disableBuiltinTools ? { tools: [] } : {},
|
|
1313
1314
|
...disableThinking ? { thinking: { type: "disabled" } } : {},
|
|
1314
1315
|
hooks: onAbAction || onAbActionFailed ? {
|
|
@@ -2284,171 +2285,6 @@ async function checkSpec(target, opts) {
|
|
|
2284
2285
|
};
|
|
2285
2286
|
}
|
|
2286
2287
|
//#endregion
|
|
2287
|
-
//#region src/drift/affected.ts
|
|
2288
|
-
const execFileP = promisify(execFile);
|
|
2289
|
-
/**
|
|
2290
|
-
* Resolve the base ref to diff against for `ccqa drift --changed`.
|
|
2291
|
-
* Precedence: explicit override > GITHUB_BASE_REF > origin/main.
|
|
2292
|
-
*/
|
|
2293
|
-
function resolveBaseRef(explicit) {
|
|
2294
|
-
if (explicit && explicit.length > 0) return explicit;
|
|
2295
|
-
const ghBase = process.env["GITHUB_BASE_REF"];
|
|
2296
|
-
if (ghBase && ghBase.length > 0) return ghBase.startsWith("origin/") ? ghBase : `origin/${ghBase}`;
|
|
2297
|
-
return "origin/main";
|
|
2298
|
-
}
|
|
2299
|
-
/**
|
|
2300
|
-
* Run `git diff --name-status base...HEAD` from `cwd` and return one entry per
|
|
2301
|
-
* changed file. Renames are reported under their NEW path with status
|
|
2302
|
-
* "renamed" — the OLD path is dropped because the spec mapping is against the
|
|
2303
|
-
* post-rename layout.
|
|
2304
|
-
*
|
|
2305
|
-
* Paths are re-rooted to be relative to `cwd`, not the git repo root. In a
|
|
2306
|
-
* monorepo where `cwd` is a sub-package (e.g. `apps/foo`), git emits paths
|
|
2307
|
-
* relative to the repo root, but specs declare relatedPaths relative to
|
|
2308
|
-
* their own package. Changes outside `cwd` are kept under their repo-root
|
|
2309
|
-
* path and flagged `outsideCwd` — they only scope a spec in when the spec
|
|
2310
|
-
* explicitly declares a repo-root-relative glob, so an unrelated PR can
|
|
2311
|
-
* never accidentally match the app-relative globs.
|
|
2312
|
-
*/
|
|
2313
|
-
async function getChangedFiles(base, cwd) {
|
|
2314
|
-
const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
|
|
2315
|
-
"diff",
|
|
2316
|
-
"--name-status",
|
|
2317
|
-
"-M",
|
|
2318
|
-
`${base}...HEAD`
|
|
2319
|
-
], {
|
|
2320
|
-
cwd,
|
|
2321
|
-
maxBuffer: 32 * 1024 * 1024
|
|
2322
|
-
})]);
|
|
2323
|
-
return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd);
|
|
2324
|
-
}
|
|
2325
|
-
/**
|
|
2326
|
-
* Convert paths in `entries` from git-repo-root relative to `cwd` relative.
|
|
2327
|
-
* Entries outside `cwd` keep their repo-root path and are flagged
|
|
2328
|
-
* `outsideCwd`. Exported for unit tests.
|
|
2329
|
-
*/
|
|
2330
|
-
function rerootChangedFiles(entries, repoRoot, cwd) {
|
|
2331
|
-
const prefix = relative(repoRoot, cwd);
|
|
2332
|
-
if (!prefix) return entries;
|
|
2333
|
-
const out = [];
|
|
2334
|
-
for (const e of entries) {
|
|
2335
|
-
const rel = relative(prefix, e.path);
|
|
2336
|
-
if (rel.startsWith("..") || rel === "") out.push({
|
|
2337
|
-
...e,
|
|
2338
|
-
outsideCwd: true
|
|
2339
|
-
});
|
|
2340
|
-
else out.push({
|
|
2341
|
-
...e,
|
|
2342
|
-
path: rel
|
|
2343
|
-
});
|
|
2344
|
-
}
|
|
2345
|
-
return out;
|
|
2346
|
-
}
|
|
2347
|
-
function parseGitDiffOutput(stdout) {
|
|
2348
|
-
const out = [];
|
|
2349
|
-
for (const line of stdout.split("\n")) {
|
|
2350
|
-
if (!line.trim()) continue;
|
|
2351
|
-
const parts = line.split(" ");
|
|
2352
|
-
const code = parts[0];
|
|
2353
|
-
if (!code) continue;
|
|
2354
|
-
if (code.startsWith("R")) {
|
|
2355
|
-
const newPath = parts[2];
|
|
2356
|
-
if (newPath) out.push({
|
|
2357
|
-
path: newPath,
|
|
2358
|
-
status: "renamed"
|
|
2359
|
-
});
|
|
2360
|
-
continue;
|
|
2361
|
-
}
|
|
2362
|
-
if (code.startsWith("C")) {
|
|
2363
|
-
const newPath = parts[2];
|
|
2364
|
-
if (newPath) out.push({
|
|
2365
|
-
path: newPath,
|
|
2366
|
-
status: "added"
|
|
2367
|
-
});
|
|
2368
|
-
continue;
|
|
2369
|
-
}
|
|
2370
|
-
const path = parts[1];
|
|
2371
|
-
if (!path) continue;
|
|
2372
|
-
switch (code[0]) {
|
|
2373
|
-
case "A":
|
|
2374
|
-
out.push({
|
|
2375
|
-
path,
|
|
2376
|
-
status: "added"
|
|
2377
|
-
});
|
|
2378
|
-
break;
|
|
2379
|
-
case "M":
|
|
2380
|
-
case "T":
|
|
2381
|
-
out.push({
|
|
2382
|
-
path,
|
|
2383
|
-
status: "modified"
|
|
2384
|
-
});
|
|
2385
|
-
break;
|
|
2386
|
-
case "D":
|
|
2387
|
-
out.push({
|
|
2388
|
-
path,
|
|
2389
|
-
status: "deleted"
|
|
2390
|
-
});
|
|
2391
|
-
break;
|
|
2392
|
-
default: out.push({
|
|
2393
|
-
path,
|
|
2394
|
-
status: "modified"
|
|
2395
|
-
});
|
|
2396
|
-
}
|
|
2397
|
-
}
|
|
2398
|
-
return out;
|
|
2399
|
-
}
|
|
2400
|
-
function stripLeadingDotSlash(s) {
|
|
2401
|
-
return s.startsWith("./") ? s.slice(2) : s;
|
|
2402
|
-
}
|
|
2403
|
-
const REGEX_CACHE = /* @__PURE__ */ new Map();
|
|
2404
|
-
/** Compiles `pattern` to a RegExp, memoized so repeated `--changed` matches don't re-build. */
|
|
2405
|
-
function compileGlob(pattern) {
|
|
2406
|
-
const cached = REGEX_CACHE.get(pattern);
|
|
2407
|
-
if (cached) return cached;
|
|
2408
|
-
const compiled = globToRegExp(stripLeadingDotSlash(pattern));
|
|
2409
|
-
REGEX_CACHE.set(pattern, compiled);
|
|
2410
|
-
return compiled;
|
|
2411
|
-
}
|
|
2412
|
-
function globToRegExp(pattern) {
|
|
2413
|
-
let re = "^";
|
|
2414
|
-
let i = 0;
|
|
2415
|
-
while (i < pattern.length) {
|
|
2416
|
-
const ch = pattern[i];
|
|
2417
|
-
if (ch === "?") {
|
|
2418
|
-
re += "[^/]";
|
|
2419
|
-
i++;
|
|
2420
|
-
continue;
|
|
2421
|
-
}
|
|
2422
|
-
if (ch !== "*") {
|
|
2423
|
-
re += /[.+^${}()|[\]\\]/.test(ch) ? "\\" + ch : ch;
|
|
2424
|
-
i++;
|
|
2425
|
-
continue;
|
|
2426
|
-
}
|
|
2427
|
-
if (pattern[i + 1] !== "*") {
|
|
2428
|
-
re += "[^/]*";
|
|
2429
|
-
i++;
|
|
2430
|
-
continue;
|
|
2431
|
-
}
|
|
2432
|
-
const hasLeadingSlash = re.endsWith("/");
|
|
2433
|
-
const hasTrailingSlash = pattern[i + 2] === "/";
|
|
2434
|
-
if (hasLeadingSlash) re = re.slice(0, -1);
|
|
2435
|
-
if (hasLeadingSlash || hasTrailingSlash) re += "(?:/?.*)?";
|
|
2436
|
-
else re += ".*";
|
|
2437
|
-
i += hasTrailingSlash ? 3 : 2;
|
|
2438
|
-
}
|
|
2439
|
-
return new RegExp(re + "$");
|
|
2440
|
-
}
|
|
2441
|
-
/**
|
|
2442
|
-
* Returns true if `changedPath` is covered by any of `relatedPaths`. An empty
|
|
2443
|
-
* `relatedPaths` returns false — callers handle the "unscoped spec" case
|
|
2444
|
-
* separately (treat the spec as always-affected) before calling this.
|
|
2445
|
-
*/
|
|
2446
|
-
function isPathAffectedBy(changedPath, relatedPaths) {
|
|
2447
|
-
const stripped = stripLeadingDotSlash(changedPath);
|
|
2448
|
-
for (const pattern of relatedPaths) if (compileGlob(pattern).test(stripped)) return true;
|
|
2449
|
-
return false;
|
|
2450
|
-
}
|
|
2451
|
-
//#endregion
|
|
2452
2288
|
//#region src/drift/auth.ts
|
|
2453
2289
|
/**
|
|
2454
2290
|
* Probe whether the host has any credential the Anthropic SDK can pick up:
|
|
@@ -3153,6 +2989,10 @@ const ReportSpecResultSchema = z.object({
|
|
|
3153
2989
|
assertions: z.array(ReportAssertionSchema).nullable(),
|
|
3154
2990
|
analysis: FailureAnalysisSchema.nullable(),
|
|
3155
2991
|
analysisSkipped: z.string().nullable(),
|
|
2992
|
+
analysisBase: z.object({
|
|
2993
|
+
ref: z.string(),
|
|
2994
|
+
sha: z.string()
|
|
2995
|
+
}).nullable().optional(),
|
|
3156
2996
|
driftIssues: z.array(DraftIssueSchema).nullable(),
|
|
3157
2997
|
failureLogExcerpt: z.string().nullable(),
|
|
3158
2998
|
diffExcerpt: z.string().nullable(),
|
|
@@ -3161,15 +3001,33 @@ const ReportSpecResultSchema = z.object({
|
|
|
3161
3001
|
artifacts: z.array(ReportArtifactSchema).optional(),
|
|
3162
3002
|
liveRun: LiveReportRunSchema.nullable()
|
|
3163
3003
|
});
|
|
3004
|
+
/**
|
|
3005
|
+
* Which rule produced the analysis baseline: "explicit" (a value was
|
|
3006
|
+
* passed), "github-base-ref" (derived from a pull_request event), or
|
|
3007
|
+
* "last-green" (per-spec baselines from the hub ledger — `baseSha` is then
|
|
3008
|
+
* null and each analyzed row carries its own `analysisBase`). Lets accuracy
|
|
3009
|
+
* numbers be stratified by baseline provenance. Single source of truth —
|
|
3010
|
+
* `src/run/git-context.ts`'s `BaseSource` type and the hub's PATCH schema
|
|
3011
|
+
* both derive from this.
|
|
3012
|
+
*/
|
|
3013
|
+
const BaseSourceSchema = z.enum([
|
|
3014
|
+
"explicit",
|
|
3015
|
+
"github-base-ref",
|
|
3016
|
+
"last-green"
|
|
3017
|
+
]);
|
|
3018
|
+
/** The report envelope's git block; also referenced by the hub's PATCH reportMeta schema. */
|
|
3019
|
+
const GitEnvelopeSchema = z.object({
|
|
3020
|
+
head: z.string().nullable(),
|
|
3021
|
+
base: z.string().nullable(),
|
|
3022
|
+
baseSha: z.string().nullable().optional(),
|
|
3023
|
+
baseSource: BaseSourceSchema.nullable().optional()
|
|
3024
|
+
});
|
|
3164
3025
|
const RunReportDataSchema = z.object({
|
|
3165
3026
|
schemaVersion: z.literal(1),
|
|
3166
3027
|
kind: z.enum(["run", "drift"]).default("run"),
|
|
3167
3028
|
createdAt: z.string(),
|
|
3168
3029
|
runId: z.string().nullable(),
|
|
3169
|
-
git:
|
|
3170
|
-
head: z.string().nullable(),
|
|
3171
|
-
base: z.string().nullable()
|
|
3172
|
-
}),
|
|
3030
|
+
git: GitEnvelopeSchema,
|
|
3173
3031
|
model: z.string().nullable(),
|
|
3174
3032
|
language: z.string().nullable().default(null),
|
|
3175
3033
|
promptVersion: z.string(),
|
|
@@ -3294,6 +3152,14 @@ function hashTriageUserPrompt(text) {
|
|
|
3294
3152
|
}
|
|
3295
3153
|
//#endregion
|
|
3296
3154
|
//#region src/report/prompt.ts
|
|
3155
|
+
/**
|
|
3156
|
+
* Fully-qualified name of the on-demand file-diff tool, as the model calls
|
|
3157
|
+
* it. Lives here (not analyze.ts) because the prompt text below references
|
|
3158
|
+
* it — one source of truth. The name is the SDK's `mcp__<server>__<tool>`
|
|
3159
|
+
* composition of the server ("diff") and tool ("changed_file_diff") that
|
|
3160
|
+
* analyze.ts registers; changing either side must keep the two in sync.
|
|
3161
|
+
*/
|
|
3162
|
+
const CHANGED_FILE_DIFF_TOOL = "mcp__diff__changed_file_diff";
|
|
3297
3163
|
function buildFailureAnalysisPrompt(input) {
|
|
3298
3164
|
const { script, specYaml, failureLog, liveTranscriptExcerpt, diffPatch, changedFiles, baseRef, driftIssues, outputLanguage = "auto", triageUserPrompt, customPrompt } = input;
|
|
3299
3165
|
const triageUserPromptBlock = buildTriageUserPromptBlock(triageUserPrompt);
|
|
@@ -3317,6 +3183,8 @@ You can call \`Grep\`, \`Glob\`, and \`Read\` against the current repository (po
|
|
|
3317
3183
|
- read the changed files in full when the truncated patch is not enough,
|
|
3318
3184
|
- check whether the element/flow the spec describes still exists in the source.
|
|
3319
3185
|
|
|
3186
|
+
You can also call \`${CHANGED_FILE_DIFF_TOOL}\` with a file path to fetch that file's diff hunk for this run's base...HEAD range. The inline patch below is scoped to this spec's relatedPaths — files OUTSIDE that scope still appear in "Changed files (name-status)" but their hunks are not inlined. Before blaming (or ruling out) such a file, fetch its diff with this tool; Read only shows you its post-change state, not what changed.
|
|
3187
|
+
|
|
3320
3188
|
You have **up to 12 tool turns**. Do NOT write, edit, run shell commands, or hit the network.
|
|
3321
3189
|
|
|
3322
3190
|
## Decision guidance
|
|
@@ -3421,19 +3289,45 @@ ${liveTranscriptExcerpt}`);
|
|
|
3421
3289
|
//#endregion
|
|
3422
3290
|
//#region src/report/analyze.ts
|
|
3423
3291
|
/**
|
|
3292
|
+
* In-process MCP server exposing one tool: the diff hunk of a named changed
|
|
3293
|
+
* file. The inline patch in the prompt is only the relatedPaths-scoped seed;
|
|
3294
|
+
* this is the pull side — the model fetches hunks for files outside that
|
|
3295
|
+
* scope (or truncated inside it) only when it decides they matter, so the
|
|
3296
|
+
* full diff never has to ride in the prompt. Read-only over data already
|
|
3297
|
+
* captured in memory: no shell, no git access granted. The server/tool
|
|
3298
|
+
* names must compose to CHANGED_FILE_DIFF_TOOL (prompt.ts), which is how
|
|
3299
|
+
* the prompt tells the model to call it.
|
|
3300
|
+
*/
|
|
3301
|
+
function buildDiffMcpServer(getFileDiff) {
|
|
3302
|
+
return createSdkMcpServer({
|
|
3303
|
+
name: "diff",
|
|
3304
|
+
version: "1.0.0",
|
|
3305
|
+
tools: [tool("changed_file_diff", "Return the unified diff (base...HEAD) of one changed file from this run's diff range. Works for ANY file listed in 'Changed files (name-status)', including files outside the spec's relatedPaths scope whose hunks are not in the inline patch.", { path: z.string().describe("File path exactly as it appears in the name-status list") }, async ({ path }) => {
|
|
3306
|
+
const hunk = getFileDiff(path);
|
|
3307
|
+
if (hunk) info(` diff tool: ${path}`);
|
|
3308
|
+
return { content: [{
|
|
3309
|
+
type: "text",
|
|
3310
|
+
text: hunk ?? `No diff found for "${path}" in this run's diff range. Check the exact path in the name-status list (paths are relative to the working directory).`
|
|
3311
|
+
}] };
|
|
3312
|
+
})]
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3424
3316
|
* Classify one failing spec into TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG /
|
|
3425
3317
|
* UNKNOWN. Same resilience contract as diagnose(): read-only tools, JSON-only
|
|
3426
3318
|
* final message, and any parse failure degrades to UNKNOWN with confidence 0
|
|
3427
3319
|
* rather than throwing — the report must always render.
|
|
3428
3320
|
*/
|
|
3429
|
-
async function analyzeFailure(input, options
|
|
3321
|
+
async function analyzeFailure(input, options) {
|
|
3430
3322
|
const { result: raw, isError } = await invokeClaudeStreaming({
|
|
3431
3323
|
prompt: buildFailureAnalysisPrompt(input),
|
|
3432
3324
|
allowedTools: [
|
|
3433
3325
|
"Read",
|
|
3434
3326
|
"Grep",
|
|
3435
|
-
"Glob"
|
|
3327
|
+
"Glob",
|
|
3328
|
+
CHANGED_FILE_DIFF_TOOL
|
|
3436
3329
|
],
|
|
3330
|
+
mcpServers: { diff: buildDiffMcpServer(options.getFileDiff) },
|
|
3437
3331
|
silenceBashLog: true,
|
|
3438
3332
|
maxTurns: 12,
|
|
3439
3333
|
...options.model ? { model: options.model } : {},
|
|
@@ -3458,61 +3352,240 @@ async function analyzeFailure(input, options = {}) {
|
|
|
3458
3352
|
sdkError: false
|
|
3459
3353
|
};
|
|
3460
3354
|
}
|
|
3461
|
-
return {
|
|
3462
|
-
analysis: unknownAnalysis(`analysis returned no parseable JSON: ${truncate$2(raw, 500)}`),
|
|
3463
|
-
raw,
|
|
3464
|
-
sdkError: false
|
|
3465
|
-
};
|
|
3466
|
-
}
|
|
3467
|
-
function unknownAnalysis(reasoning) {
|
|
3468
|
-
return {
|
|
3469
|
-
label: "UNKNOWN",
|
|
3470
|
-
confidence: 0,
|
|
3471
|
-
subDiagnosis: "NONE",
|
|
3472
|
-
headline: "",
|
|
3473
|
-
recommendation: "",
|
|
3474
|
-
evidence: [],
|
|
3475
|
-
reasoning
|
|
3476
|
-
};
|
|
3355
|
+
return {
|
|
3356
|
+
analysis: unknownAnalysis(`analysis returned no parseable JSON: ${truncate$2(raw, 500)}`),
|
|
3357
|
+
raw,
|
|
3358
|
+
sdkError: false
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
function unknownAnalysis(reasoning) {
|
|
3362
|
+
return {
|
|
3363
|
+
label: "UNKNOWN",
|
|
3364
|
+
confidence: 0,
|
|
3365
|
+
subDiagnosis: "NONE",
|
|
3366
|
+
headline: "",
|
|
3367
|
+
recommendation: "",
|
|
3368
|
+
evidence: [],
|
|
3369
|
+
reasoning
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
const LABELS = new Set(PREDICTED_LABELS);
|
|
3373
|
+
const SUB_SET = new Set(SUB_DIAGNOSES);
|
|
3374
|
+
/**
|
|
3375
|
+
* Manual, lenient normalisation (mirrors diagnose's normaliseResult): a
|
|
3376
|
+
* missing/extra field should degrade gracefully, not reject the whole
|
|
3377
|
+
* prediction — only an unrecognisable label makes the candidate unusable.
|
|
3378
|
+
*/
|
|
3379
|
+
function normaliseFailureAnalysis(parsed) {
|
|
3380
|
+
if (!isObject(parsed)) return null;
|
|
3381
|
+
const label = parsed["label"];
|
|
3382
|
+
if (typeof label !== "string" || !LABELS.has(label)) return null;
|
|
3383
|
+
const confidence = typeof parsed["confidence"] === "number" ? clamp(parsed["confidence"], 0, 1) : 0;
|
|
3384
|
+
const reasoning = typeof parsed["reasoning"] === "string" ? parsed["reasoning"] : "";
|
|
3385
|
+
const headline = typeof parsed["headline"] === "string" ? parsed["headline"] : "";
|
|
3386
|
+
const recommendation = typeof parsed["recommendation"] === "string" ? parsed["recommendation"] : "";
|
|
3387
|
+
const rawSub = parsed["subDiagnosis"];
|
|
3388
|
+
const subDiagnosis = typeof rawSub === "string" && SUB_SET.has(rawSub) ? rawSub : "NONE";
|
|
3389
|
+
const evidence = [];
|
|
3390
|
+
if (Array.isArray(parsed["evidence"])) for (const item of parsed["evidence"]) {
|
|
3391
|
+
if (!isObject(item)) continue;
|
|
3392
|
+
const detail = typeof item["detail"] === "string" ? item["detail"] : null;
|
|
3393
|
+
if (detail === null) continue;
|
|
3394
|
+
const file = typeof item["file"] === "string" ? item["file"] : void 0;
|
|
3395
|
+
evidence.push(file !== void 0 ? {
|
|
3396
|
+
file,
|
|
3397
|
+
detail
|
|
3398
|
+
} : { detail });
|
|
3399
|
+
if (evidence.length >= 3) break;
|
|
3400
|
+
}
|
|
3401
|
+
return {
|
|
3402
|
+
label,
|
|
3403
|
+
confidence,
|
|
3404
|
+
subDiagnosis,
|
|
3405
|
+
headline,
|
|
3406
|
+
recommendation,
|
|
3407
|
+
evidence,
|
|
3408
|
+
reasoning
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
//#endregion
|
|
3412
|
+
//#region src/drift/affected.ts
|
|
3413
|
+
const execFileP = promisify(execFile);
|
|
3414
|
+
/**
|
|
3415
|
+
* GITHUB_BASE_REF holds a bare branch name (e.g. "main"); the local checkout
|
|
3416
|
+
* only has it as a remote-tracking ref, so prefix `origin/` unless already
|
|
3417
|
+
* qualified. Shared by `ccqa drift`'s resolveBaseRef and `ccqa run`'s
|
|
3418
|
+
* resolveAnalysisBase so the rule can't drift between them.
|
|
3419
|
+
*/
|
|
3420
|
+
function normalizeGithubBaseRef(ref) {
|
|
3421
|
+
return ref.startsWith("origin/") ? ref : `origin/${ref}`;
|
|
3422
|
+
}
|
|
3423
|
+
/**
|
|
3424
|
+
* Resolve the base ref to diff against for `ccqa drift --changed`.
|
|
3425
|
+
* Precedence: explicit override > GITHUB_BASE_REF > origin/main.
|
|
3426
|
+
*
|
|
3427
|
+
* Note: this is the `ccqa drift` rule. `ccqa run` resolves its baseline via
|
|
3428
|
+
* `src/run/git-context.ts` instead, which has no origin/main fallback — see
|
|
3429
|
+
* the rationale there.
|
|
3430
|
+
*/
|
|
3431
|
+
function resolveBaseRef(explicit) {
|
|
3432
|
+
if (explicit && explicit.length > 0) return explicit;
|
|
3433
|
+
const ghBase = process.env["GITHUB_BASE_REF"];
|
|
3434
|
+
if (ghBase && ghBase.length > 0) return normalizeGithubBaseRef(ghBase);
|
|
3435
|
+
return "origin/main";
|
|
3436
|
+
}
|
|
3437
|
+
/**
|
|
3438
|
+
* Run `git diff --name-status base...HEAD` from `cwd` and return one entry per
|
|
3439
|
+
* changed file. Renames are reported under their NEW path with status
|
|
3440
|
+
* "renamed" — the OLD path is dropped because the spec mapping is against the
|
|
3441
|
+
* post-rename layout.
|
|
3442
|
+
*
|
|
3443
|
+
* Paths are re-rooted to be relative to `cwd`, not the git repo root. In a
|
|
3444
|
+
* monorepo where `cwd` is a sub-package (e.g. `apps/foo`), git emits paths
|
|
3445
|
+
* relative to the repo root, but specs declare relatedPaths relative to
|
|
3446
|
+
* their own package. Changes outside `cwd` are kept under their repo-root
|
|
3447
|
+
* path and flagged `outsideCwd` — they only scope a spec in when the spec
|
|
3448
|
+
* explicitly declares a repo-root-relative glob, so an unrelated PR can
|
|
3449
|
+
* never accidentally match the app-relative globs.
|
|
3450
|
+
*/
|
|
3451
|
+
async function getChangedFiles(base, cwd) {
|
|
3452
|
+
const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
|
|
3453
|
+
"diff",
|
|
3454
|
+
"--name-status",
|
|
3455
|
+
"-M",
|
|
3456
|
+
`${base}...HEAD`
|
|
3457
|
+
], {
|
|
3458
|
+
cwd,
|
|
3459
|
+
maxBuffer: 32 * 1024 * 1024
|
|
3460
|
+
})]);
|
|
3461
|
+
return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd);
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Convert paths in `entries` from git-repo-root relative to `cwd` relative.
|
|
3465
|
+
* Entries outside `cwd` keep their repo-root path and are flagged
|
|
3466
|
+
* `outsideCwd`. Exported for unit tests.
|
|
3467
|
+
*/
|
|
3468
|
+
function rerootChangedFiles(entries, repoRoot, cwd) {
|
|
3469
|
+
const prefix = relative(repoRoot, cwd);
|
|
3470
|
+
if (!prefix) return entries;
|
|
3471
|
+
const out = [];
|
|
3472
|
+
for (const e of entries) {
|
|
3473
|
+
const rel = relative(prefix, e.path);
|
|
3474
|
+
if (rel.startsWith("..") || rel === "") out.push({
|
|
3475
|
+
...e,
|
|
3476
|
+
outsideCwd: true
|
|
3477
|
+
});
|
|
3478
|
+
else out.push({
|
|
3479
|
+
...e,
|
|
3480
|
+
path: rel
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3483
|
+
return out;
|
|
3484
|
+
}
|
|
3485
|
+
function parseGitDiffOutput(stdout) {
|
|
3486
|
+
const out = [];
|
|
3487
|
+
for (const line of stdout.split("\n")) {
|
|
3488
|
+
if (!line.trim()) continue;
|
|
3489
|
+
const parts = line.split(" ");
|
|
3490
|
+
const code = parts[0];
|
|
3491
|
+
if (!code) continue;
|
|
3492
|
+
if (code.startsWith("R")) {
|
|
3493
|
+
const newPath = parts[2];
|
|
3494
|
+
if (newPath) out.push({
|
|
3495
|
+
path: newPath,
|
|
3496
|
+
status: "renamed"
|
|
3497
|
+
});
|
|
3498
|
+
continue;
|
|
3499
|
+
}
|
|
3500
|
+
if (code.startsWith("C")) {
|
|
3501
|
+
const newPath = parts[2];
|
|
3502
|
+
if (newPath) out.push({
|
|
3503
|
+
path: newPath,
|
|
3504
|
+
status: "added"
|
|
3505
|
+
});
|
|
3506
|
+
continue;
|
|
3507
|
+
}
|
|
3508
|
+
const path = parts[1];
|
|
3509
|
+
if (!path) continue;
|
|
3510
|
+
switch (code[0]) {
|
|
3511
|
+
case "A":
|
|
3512
|
+
out.push({
|
|
3513
|
+
path,
|
|
3514
|
+
status: "added"
|
|
3515
|
+
});
|
|
3516
|
+
break;
|
|
3517
|
+
case "M":
|
|
3518
|
+
case "T":
|
|
3519
|
+
out.push({
|
|
3520
|
+
path,
|
|
3521
|
+
status: "modified"
|
|
3522
|
+
});
|
|
3523
|
+
break;
|
|
3524
|
+
case "D":
|
|
3525
|
+
out.push({
|
|
3526
|
+
path,
|
|
3527
|
+
status: "deleted"
|
|
3528
|
+
});
|
|
3529
|
+
break;
|
|
3530
|
+
default: out.push({
|
|
3531
|
+
path,
|
|
3532
|
+
status: "modified"
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
return out;
|
|
3537
|
+
}
|
|
3538
|
+
/** Normalize a leading `./` away so diff paths and relatedPaths globs compare. */
|
|
3539
|
+
function stripLeadingDotSlash(s) {
|
|
3540
|
+
return s.startsWith("./") ? s.slice(2) : s;
|
|
3541
|
+
}
|
|
3542
|
+
const REGEX_CACHE = /* @__PURE__ */ new Map();
|
|
3543
|
+
/** Compiles `pattern` to a RegExp, memoized so repeated `--changed` matches don't re-build. */
|
|
3544
|
+
function compileGlob(pattern) {
|
|
3545
|
+
const cached = REGEX_CACHE.get(pattern);
|
|
3546
|
+
if (cached) return cached;
|
|
3547
|
+
const compiled = globToRegExp(stripLeadingDotSlash(pattern));
|
|
3548
|
+
REGEX_CACHE.set(pattern, compiled);
|
|
3549
|
+
return compiled;
|
|
3550
|
+
}
|
|
3551
|
+
function globToRegExp(pattern) {
|
|
3552
|
+
let re = "^";
|
|
3553
|
+
let i = 0;
|
|
3554
|
+
while (i < pattern.length) {
|
|
3555
|
+
const ch = pattern[i];
|
|
3556
|
+
if (ch === "?") {
|
|
3557
|
+
re += "[^/]";
|
|
3558
|
+
i++;
|
|
3559
|
+
continue;
|
|
3560
|
+
}
|
|
3561
|
+
if (ch !== "*") {
|
|
3562
|
+
re += /[.+^${}()|[\]\\]/.test(ch) ? "\\" + ch : ch;
|
|
3563
|
+
i++;
|
|
3564
|
+
continue;
|
|
3565
|
+
}
|
|
3566
|
+
if (pattern[i + 1] !== "*") {
|
|
3567
|
+
re += "[^/]*";
|
|
3568
|
+
i++;
|
|
3569
|
+
continue;
|
|
3570
|
+
}
|
|
3571
|
+
const hasLeadingSlash = re.endsWith("/");
|
|
3572
|
+
const hasTrailingSlash = pattern[i + 2] === "/";
|
|
3573
|
+
if (hasLeadingSlash) re = re.slice(0, -1);
|
|
3574
|
+
if (hasLeadingSlash || hasTrailingSlash) re += "(?:/?.*)?";
|
|
3575
|
+
else re += ".*";
|
|
3576
|
+
i += hasTrailingSlash ? 3 : 2;
|
|
3577
|
+
}
|
|
3578
|
+
return new RegExp(re + "$");
|
|
3477
3579
|
}
|
|
3478
|
-
const LABELS = new Set(PREDICTED_LABELS);
|
|
3479
|
-
const SUB_SET = new Set(SUB_DIAGNOSES);
|
|
3480
3580
|
/**
|
|
3481
|
-
*
|
|
3482
|
-
*
|
|
3483
|
-
*
|
|
3581
|
+
* Returns true if `changedPath` is covered by any of `relatedPaths`. An empty
|
|
3582
|
+
* `relatedPaths` returns false — callers handle the "unscoped spec" case
|
|
3583
|
+
* separately (treat the spec as always-affected) before calling this.
|
|
3484
3584
|
*/
|
|
3485
|
-
function
|
|
3486
|
-
|
|
3487
|
-
const
|
|
3488
|
-
|
|
3489
|
-
const confidence = typeof parsed["confidence"] === "number" ? clamp(parsed["confidence"], 0, 1) : 0;
|
|
3490
|
-
const reasoning = typeof parsed["reasoning"] === "string" ? parsed["reasoning"] : "";
|
|
3491
|
-
const headline = typeof parsed["headline"] === "string" ? parsed["headline"] : "";
|
|
3492
|
-
const recommendation = typeof parsed["recommendation"] === "string" ? parsed["recommendation"] : "";
|
|
3493
|
-
const rawSub = parsed["subDiagnosis"];
|
|
3494
|
-
const subDiagnosis = typeof rawSub === "string" && SUB_SET.has(rawSub) ? rawSub : "NONE";
|
|
3495
|
-
const evidence = [];
|
|
3496
|
-
if (Array.isArray(parsed["evidence"])) for (const item of parsed["evidence"]) {
|
|
3497
|
-
if (!isObject(item)) continue;
|
|
3498
|
-
const detail = typeof item["detail"] === "string" ? item["detail"] : null;
|
|
3499
|
-
if (detail === null) continue;
|
|
3500
|
-
const file = typeof item["file"] === "string" ? item["file"] : void 0;
|
|
3501
|
-
evidence.push(file !== void 0 ? {
|
|
3502
|
-
file,
|
|
3503
|
-
detail
|
|
3504
|
-
} : { detail });
|
|
3505
|
-
if (evidence.length >= 3) break;
|
|
3506
|
-
}
|
|
3507
|
-
return {
|
|
3508
|
-
label,
|
|
3509
|
-
confidence,
|
|
3510
|
-
subDiagnosis,
|
|
3511
|
-
headline,
|
|
3512
|
-
recommendation,
|
|
3513
|
-
evidence,
|
|
3514
|
-
reasoning
|
|
3515
|
-
};
|
|
3585
|
+
function isPathAffectedBy(changedPath, relatedPaths) {
|
|
3586
|
+
const stripped = stripLeadingDotSlash(changedPath);
|
|
3587
|
+
for (const pattern of relatedPaths) if (compileGlob(pattern).test(stripped)) return true;
|
|
3588
|
+
return false;
|
|
3516
3589
|
}
|
|
3517
3590
|
/**
|
|
3518
3591
|
* Capture the PR diff used as context for failure analysis. `--relative`
|
|
@@ -3627,6 +3700,249 @@ function scopePatchForSpec(patch, relatedPaths, caps = {}) {
|
|
|
3627
3700
|
return parts.join("\n");
|
|
3628
3701
|
}
|
|
3629
3702
|
//#endregion
|
|
3703
|
+
//#region src/run/diff-provider.ts
|
|
3704
|
+
/**
|
|
3705
|
+
* Cap on one on-demand file-diff response. Larger than the inline seed's
|
|
3706
|
+
* per-file cap (the model explicitly asked for this file), but still bounded
|
|
3707
|
+
* so a generated-file hunk can't blow the context — the truncation note
|
|
3708
|
+
* points at Read for the file's full current state.
|
|
3709
|
+
*/
|
|
3710
|
+
const FILE_DIFF_RESPONSE_CAP = 16 * 1024;
|
|
3711
|
+
/** Find `path`'s section in a split patch and cap it. Exported for tests. */
|
|
3712
|
+
function lookupFileDiff(sections, path) {
|
|
3713
|
+
const normalized = stripLeadingDotSlash(path);
|
|
3714
|
+
const section = sections.find((s) => s.path === normalized);
|
|
3715
|
+
if (!section) return null;
|
|
3716
|
+
if (section.body.length <= 16384) return section.body;
|
|
3717
|
+
return `${section.body.slice(0, FILE_DIFF_RESPONSE_CAP)}\n[truncated: ${section.body.length - FILE_DIFF_RESPONSE_CAP} more chars — Read the file for its full current state]`;
|
|
3718
|
+
}
|
|
3719
|
+
function createDiffProvider(args) {
|
|
3720
|
+
const { resolveBase, cwd } = args;
|
|
3721
|
+
const captures = /* @__PURE__ */ new Map();
|
|
3722
|
+
let relatedPathsIndex = null;
|
|
3723
|
+
function capture(sha) {
|
|
3724
|
+
const cached = captures.get(sha);
|
|
3725
|
+
if (cached) return cached;
|
|
3726
|
+
const pending = (async () => {
|
|
3727
|
+
const result = await capturePrDiff(sha, cwd);
|
|
3728
|
+
if (!result.ok) return {
|
|
3729
|
+
sections: null,
|
|
3730
|
+
nameStatus: null,
|
|
3731
|
+
error: result.error
|
|
3732
|
+
};
|
|
3733
|
+
const { patch, nameStatus } = result.diff;
|
|
3734
|
+
return {
|
|
3735
|
+
sections: patch.length > 0 ? splitPatchByFile(patch) : [],
|
|
3736
|
+
nameStatus,
|
|
3737
|
+
error: null
|
|
3738
|
+
};
|
|
3739
|
+
})();
|
|
3740
|
+
captures.set(sha, pending);
|
|
3741
|
+
return pending;
|
|
3742
|
+
}
|
|
3743
|
+
/** relatedPaths for every spec, read once from the feature tree. */
|
|
3744
|
+
function relatedPaths() {
|
|
3745
|
+
relatedPathsIndex ??= listFeatureTree(cwd).then((tree) => new Map(tree.flatMap((f) => f.specs.map((s) => [specKey({
|
|
3746
|
+
featureName: f.featureName,
|
|
3747
|
+
specName: s.specName
|
|
3748
|
+
}), s.relatedPaths ?? null]))));
|
|
3749
|
+
return relatedPathsIndex;
|
|
3750
|
+
}
|
|
3751
|
+
return { async forSpec(spec) {
|
|
3752
|
+
const resolved = await resolveBase(spec);
|
|
3753
|
+
if (!resolved.ok) return resolved;
|
|
3754
|
+
const [captured, index] = await Promise.all([capture(resolved.base.sha), relatedPaths()]);
|
|
3755
|
+
const scope = index.get(specKey(spec)) ?? null;
|
|
3756
|
+
const sections = captured.sections;
|
|
3757
|
+
return {
|
|
3758
|
+
ok: true,
|
|
3759
|
+
base: resolved.base,
|
|
3760
|
+
patch: sections ? scopePatchForSpec(sections, scope) : null,
|
|
3761
|
+
nameStatus: captured.nameStatus,
|
|
3762
|
+
error: captured.error,
|
|
3763
|
+
fileDiff: (path) => sections ? lookupFileDiff(sections, path) : null
|
|
3764
|
+
};
|
|
3765
|
+
} };
|
|
3766
|
+
}
|
|
3767
|
+
//#endregion
|
|
3768
|
+
//#region src/run/errors.ts
|
|
3769
|
+
/**
|
|
3770
|
+
* Usage error (bad flag combination, broken profile, failed `git diff`, …)
|
|
3771
|
+
* thrown by the `run` pipeline and the helpers it calls, e.g.
|
|
3772
|
+
* `collectChangedSpecs`. `executeRun` never calls `process.exit`, so each
|
|
3773
|
+
* host maps this itself: the CLI action catches it and exits with
|
|
3774
|
+
* `exitCode`; the hub runner records it as a run-level error.
|
|
3775
|
+
*/
|
|
3776
|
+
var RunUsageError = class extends Error {
|
|
3777
|
+
exitCode = 2;
|
|
3778
|
+
constructor(message) {
|
|
3779
|
+
super(message);
|
|
3780
|
+
this.name = "RunUsageError";
|
|
3781
|
+
}
|
|
3782
|
+
};
|
|
3783
|
+
//#endregion
|
|
3784
|
+
//#region src/run/git-context.ts
|
|
3785
|
+
/** The `--failure-analysis` value that selects per-spec hub-ledger baselines. */
|
|
3786
|
+
const LAST_GREEN = "last-green";
|
|
3787
|
+
/** Resolve `ref` to a full commit sha, or null when it does not exist locally. */
|
|
3788
|
+
async function resolveCommitSha(ref, cwd) {
|
|
3789
|
+
try {
|
|
3790
|
+
const { stdout } = await execFileP("git", [
|
|
3791
|
+
"rev-parse",
|
|
3792
|
+
"--verify",
|
|
3793
|
+
"--quiet",
|
|
3794
|
+
`${ref}^{commit}`
|
|
3795
|
+
], { cwd });
|
|
3796
|
+
return stdout.trim() || null;
|
|
3797
|
+
} catch {
|
|
3798
|
+
return null;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
/**
|
|
3802
|
+
* Resolve a `[base]` flag value (from `--failure-analysis [base]` or
|
|
3803
|
+
* `--changed [base]`) to a verified baseline, failing fast — before any spec
|
|
3804
|
+
* runs — when it cannot be resolved.
|
|
3805
|
+
*
|
|
3806
|
+
* - a string value is an explicit ref;
|
|
3807
|
+
* - bare `true` derives the ref from GITHUB_BASE_REF (pull_request events)
|
|
3808
|
+
* and errors outside that context;
|
|
3809
|
+
* - the ref must resolve to a local commit, so a shallow CI checkout that
|
|
3810
|
+
* never fetched the base surfaces here as an actionable error instead of
|
|
3811
|
+
* an empty diff downstream.
|
|
3812
|
+
*
|
|
3813
|
+
* `flagName` only shapes the error messages.
|
|
3814
|
+
*/
|
|
3815
|
+
async function resolveAnalysisBase(flagValue, flagName, cwd) {
|
|
3816
|
+
let ref;
|
|
3817
|
+
let source;
|
|
3818
|
+
if (flagValue === "last-green") throw new RunUsageError(`${flagName}=${LAST_GREEN} is not supported — last-green baselines are per-spec and only apply to --failure-analysis`);
|
|
3819
|
+
if (typeof flagValue === "string") {
|
|
3820
|
+
ref = flagValue;
|
|
3821
|
+
source = "explicit";
|
|
3822
|
+
} else {
|
|
3823
|
+
const ghBase = process.env["GITHUB_BASE_REF"];
|
|
3824
|
+
if (!ghBase) throw new RunUsageError(`${flagName} without a base needs GITHUB_BASE_REF (a pull_request workflow); outside that context pass the base explicitly, e.g. ${flagName}=origin/main`);
|
|
3825
|
+
ref = normalizeGithubBaseRef(ghBase);
|
|
3826
|
+
source = "github-base-ref";
|
|
3827
|
+
}
|
|
3828
|
+
const sha = await resolveCommitSha(ref, cwd);
|
|
3829
|
+
if (sha === null) throw new RunUsageError(`${flagName}: '${ref}' is not a resolvable git ref in this checkout. If this is CI, the base may not be fetched (try fetch-depth: 0). If '${ref}' was meant as a spec target, put spec targets before flags or use ${flagName}=<ref>.`);
|
|
3830
|
+
return {
|
|
3831
|
+
ref,
|
|
3832
|
+
sha,
|
|
3833
|
+
source
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
3836
|
+
//#endregion
|
|
3837
|
+
//#region src/cli/git-branch.ts
|
|
3838
|
+
/** Best-effort current branch: CI env vars first, then git, else null. */
|
|
3839
|
+
async function detectBranch(cwd) {
|
|
3840
|
+
const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
|
|
3841
|
+
if (fromEnv) return fromEnv;
|
|
3842
|
+
try {
|
|
3843
|
+
const { stdout } = await execFileP("git", [
|
|
3844
|
+
"rev-parse",
|
|
3845
|
+
"--abbrev-ref",
|
|
3846
|
+
"HEAD"
|
|
3847
|
+
], { cwd });
|
|
3848
|
+
const branch = stdout.trim();
|
|
3849
|
+
return branch && branch !== "HEAD" ? branch : null;
|
|
3850
|
+
} catch {
|
|
3851
|
+
return null;
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
/** Best-effort current commit SHA, or null (e.g. not a git repo). */
|
|
3855
|
+
async function getGitHead(cwd) {
|
|
3856
|
+
try {
|
|
3857
|
+
const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
|
|
3858
|
+
return stdout.trim() || null;
|
|
3859
|
+
} catch {
|
|
3860
|
+
return null;
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
//#endregion
|
|
3864
|
+
//#region src/run/last-green.ts
|
|
3865
|
+
/**
|
|
3866
|
+
* The repo's default branch name (e.g. "main"), from origin's HEAD ref.
|
|
3867
|
+
* Falls back to "main" when origin isn't configured — the lookup then just
|
|
3868
|
+
* queries a possibly-empty ledger bucket, which is harmless.
|
|
3869
|
+
*/
|
|
3870
|
+
async function detectDefaultBranch(cwd) {
|
|
3871
|
+
try {
|
|
3872
|
+
const { stdout } = await execFileP("git", [
|
|
3873
|
+
"symbolic-ref",
|
|
3874
|
+
"--short",
|
|
3875
|
+
"refs/remotes/origin/HEAD"
|
|
3876
|
+
], { cwd });
|
|
3877
|
+
const ref = stdout.trim();
|
|
3878
|
+
return ref.startsWith("origin/") ? ref.slice(7) : ref || "main";
|
|
3879
|
+
} catch {
|
|
3880
|
+
return "main";
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
/**
|
|
3884
|
+
* Fetch the last-green ledger for this run — one hub round trip, logged as
|
|
3885
|
+
* the run's analysis-base meta line. Fails fast (RunUsageError) when the hub
|
|
3886
|
+
* can't serve it: `--failure-analysis=last-green` explicitly opted into
|
|
3887
|
+
* hub-backed baselines, so a broken hub connection is a usage error, never a
|
|
3888
|
+
* silent no-baseline run.
|
|
3889
|
+
*/
|
|
3890
|
+
async function fetchLastGreenLedger(hubCtx, profile, cwd) {
|
|
3891
|
+
const [fallbackBranch, detectedBranch] = await Promise.all([detectDefaultBranch(cwd), detectBranch(cwd)]);
|
|
3892
|
+
const branch = detectedBranch ?? fallbackBranch;
|
|
3893
|
+
let entries;
|
|
3894
|
+
try {
|
|
3895
|
+
entries = await hubCtx.hub.getLastGreen(hubCtx.project, {
|
|
3896
|
+
branch,
|
|
3897
|
+
fallbackBranch,
|
|
3898
|
+
...profile ? { profile } : {}
|
|
3899
|
+
});
|
|
3900
|
+
} catch (err) {
|
|
3901
|
+
throw new RunUsageError(`--failure-analysis=${LAST_GREEN}: could not fetch the last-green ledger from the hub: ${err instanceof Error ? err.message : String(err)}`);
|
|
3902
|
+
}
|
|
3903
|
+
const n = Object.keys(entries).length;
|
|
3904
|
+
const scope = branch === fallbackBranch ? branch : `${branch} → ${fallbackBranch}`;
|
|
3905
|
+
meta("analysis-base", `${LAST_GREEN} (${n} spec baseline${n === 1 ? "" : "s"}, branch ${scope})`);
|
|
3906
|
+
return entries;
|
|
3907
|
+
}
|
|
3908
|
+
/**
|
|
3909
|
+
* Per-spec baseline resolver for `--failure-analysis=last-green`. A spec
|
|
3910
|
+
* missing from the ledger (never green on a pushed run yet) or whose
|
|
3911
|
+
* baseline commit isn't in this checkout resolves to a skip — the run
|
|
3912
|
+
* continues; only that spec's classification is withheld, with the reason
|
|
3913
|
+
* recorded in its report row. Sha existence checks are memoized per commit.
|
|
3914
|
+
*/
|
|
3915
|
+
function createLastGreenResolver(entries, cwd) {
|
|
3916
|
+
const shaChecks = /* @__PURE__ */ new Map();
|
|
3917
|
+
const checkSha = (sha) => {
|
|
3918
|
+
const cached = shaChecks.get(sha);
|
|
3919
|
+
if (cached) return cached;
|
|
3920
|
+
const pending = resolveCommitSha(sha, cwd);
|
|
3921
|
+
shaChecks.set(sha, pending);
|
|
3922
|
+
return pending;
|
|
3923
|
+
};
|
|
3924
|
+
return async (spec) => {
|
|
3925
|
+
const entry = entries[specKey(spec)];
|
|
3926
|
+
if (!entry) return {
|
|
3927
|
+
ok: false,
|
|
3928
|
+
skip: "no last-green baseline for this spec on the hub yet (recorded once the spec passes on a pushed run)"
|
|
3929
|
+
};
|
|
3930
|
+
const sha = await checkSha(entry.gitHead);
|
|
3931
|
+
if (!sha) return {
|
|
3932
|
+
ok: false,
|
|
3933
|
+
skip: `last-green commit ${entry.gitHead.slice(0, 12)} is not in this checkout (shallow clone? try fetch-depth: 0)`
|
|
3934
|
+
};
|
|
3935
|
+
return {
|
|
3936
|
+
ok: true,
|
|
3937
|
+
base: {
|
|
3938
|
+
ref: LAST_GREEN,
|
|
3939
|
+
sha,
|
|
3940
|
+
source: "last-green"
|
|
3941
|
+
}
|
|
3942
|
+
};
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
//#endregion
|
|
3630
3946
|
//#region src/report/github-format.ts
|
|
3631
3947
|
/**
|
|
3632
3948
|
* Build GitHub Actions `::error::` annotation lines for every failed spec in
|
|
@@ -4584,33 +4900,6 @@ function resolvePromptLocalPath(name, cwd) {
|
|
|
4584
4900
|
return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
|
|
4585
4901
|
}
|
|
4586
4902
|
//#endregion
|
|
4587
|
-
//#region src/cli/git-branch.ts
|
|
4588
|
-
/** Best-effort current branch: CI env vars first, then git, else null. */
|
|
4589
|
-
async function detectBranch(cwd) {
|
|
4590
|
-
const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
|
|
4591
|
-
if (fromEnv) return fromEnv;
|
|
4592
|
-
try {
|
|
4593
|
-
const { stdout } = await execFileP("git", [
|
|
4594
|
-
"rev-parse",
|
|
4595
|
-
"--abbrev-ref",
|
|
4596
|
-
"HEAD"
|
|
4597
|
-
], { cwd });
|
|
4598
|
-
const branch = stdout.trim();
|
|
4599
|
-
return branch && branch !== "HEAD" ? branch : null;
|
|
4600
|
-
} catch {
|
|
4601
|
-
return null;
|
|
4602
|
-
}
|
|
4603
|
-
}
|
|
4604
|
-
/** Best-effort current commit SHA, or null (e.g. not a git repo). */
|
|
4605
|
-
async function getGitHead(cwd) {
|
|
4606
|
-
try {
|
|
4607
|
-
const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
|
|
4608
|
-
return stdout.trim() || null;
|
|
4609
|
-
} catch {
|
|
4610
|
-
return null;
|
|
4611
|
-
}
|
|
4612
|
-
}
|
|
4613
|
-
//#endregion
|
|
4614
4903
|
//#region src/cli/hub.ts
|
|
4615
4904
|
/**
|
|
4616
4905
|
* `ccqa hub` — the client side of the ccqa hub (a results/secret control
|
|
@@ -5540,22 +5829,14 @@ async function runLiveSpecs(specs, opts) {
|
|
|
5540
5829
|
const userPromptBundle = await loadPromptBundleFromHub(opts.hubContext ?? null, "live");
|
|
5541
5830
|
if (userPromptBundle !== null) meta("prompt", userPromptBundle.loaded.join(" + "));
|
|
5542
5831
|
const userPromptSuffix = userPromptBundle?.text ?? null;
|
|
5543
|
-
const
|
|
5832
|
+
const diffProvider = opts.diffProvider ?? null;
|
|
5833
|
+
const failureAnalysisEnabled = diffProvider != null;
|
|
5544
5834
|
const driftAuditEnabled = failureAnalysisEnabled && opts.driftAudit !== false;
|
|
5545
5835
|
const auth = failureAnalysisEnabled ? driftAuthAvailable() : {
|
|
5546
5836
|
ok: false,
|
|
5547
5837
|
reason: "disabled"
|
|
5548
5838
|
};
|
|
5549
5839
|
if (failureAnalysisEnabled && !auth.ok) info(`failure analysis skipped (${auth.reason})`);
|
|
5550
|
-
const baseRef = resolveBaseRef(opts.base);
|
|
5551
|
-
let diff = {
|
|
5552
|
-
ok: false,
|
|
5553
|
-
error: "diff not captured"
|
|
5554
|
-
};
|
|
5555
|
-
if (failureAnalysisEnabled && auth.ok) {
|
|
5556
|
-
diff = await capturePrDiff(baseRef, cwd);
|
|
5557
|
-
if (!diff.ok) info(`failure analysis: source diff unavailable (${diff.error}) — analyzing without diff context`);
|
|
5558
|
-
}
|
|
5559
5840
|
const reportDir = opts.reportDir ?? ".";
|
|
5560
5841
|
const concurrency = Math.max(1, opts.concurrency ?? 1);
|
|
5561
5842
|
const built = await runPool(specs, concurrency, (spec, i) => {
|
|
@@ -5577,10 +5858,8 @@ async function runLiveSpecs(specs, opts) {
|
|
|
5577
5858
|
};
|
|
5578
5859
|
const row = await buildLiveReportRow(outcome, {
|
|
5579
5860
|
auth,
|
|
5580
|
-
|
|
5581
|
-
baseRef,
|
|
5861
|
+
diffProvider,
|
|
5582
5862
|
reportDir,
|
|
5583
|
-
failureAnalysisEnabled,
|
|
5584
5863
|
driftAuditEnabled
|
|
5585
5864
|
}, opts, cwd);
|
|
5586
5865
|
await opts.report?.upsert(row);
|
|
@@ -5616,26 +5895,29 @@ async function buildLiveReportRow(r, ctx, opts, cwd) {
|
|
|
5616
5895
|
reportDir: ctx.reportDir
|
|
5617
5896
|
});
|
|
5618
5897
|
const driftForSpec = ctx.driftAuditEnabled && r.result.status === "failed" ? await runDriftAuditOne(r, opts, cwd) : null;
|
|
5619
|
-
const analysis = ctx.
|
|
5898
|
+
const analysis = ctx.diffProvider && r.result.status === "failed" ? await analyzeOneLiveFailure(r, ctx.diffProvider, driftForSpec, ctx.auth, opts, cwd) : void 0;
|
|
5620
5899
|
return {
|
|
5621
5900
|
...base,
|
|
5622
5901
|
driftIssues: driftForSpec,
|
|
5623
|
-
...analysisFieldsFor(analysis, r.result.status
|
|
5902
|
+
...analysisFieldsFor(analysis, r.result.status)
|
|
5624
5903
|
};
|
|
5625
5904
|
}
|
|
5626
5905
|
/**
|
|
5627
5906
|
* Merge analysis-related fields into the report row. The unattempted-failure
|
|
5628
5907
|
* branch exists so the report distinguishes "we tried and gave up" (auth /
|
|
5629
|
-
* spec.yaml missing) from "we deliberately did not run the classifier"
|
|
5908
|
+
* spec.yaml missing) from "we deliberately did not run the classifier" —
|
|
5909
|
+
* `a` is undefined for a failed spec exactly when analysis was not requested
|
|
5910
|
+
* (no diffProvider), so no separate flag is needed.
|
|
5630
5911
|
*/
|
|
5631
|
-
function analysisFieldsFor(a, status
|
|
5912
|
+
function analysisFieldsFor(a, status) {
|
|
5632
5913
|
if (a) return {
|
|
5633
5914
|
analysis: a.analysis,
|
|
5634
5915
|
analysisSkipped: a.analysisSkipped,
|
|
5635
5916
|
failureLogExcerpt: a.failureLogExcerpt,
|
|
5636
|
-
diffExcerpt: a.diffExcerpt
|
|
5917
|
+
diffExcerpt: a.diffExcerpt,
|
|
5918
|
+
...a.analysisBase ? { analysisBase: a.analysisBase } : {}
|
|
5637
5919
|
};
|
|
5638
|
-
if (
|
|
5920
|
+
if (status === "failed") return { analysisSkipped: "skipped: --failure-analysis not enabled" };
|
|
5639
5921
|
return {};
|
|
5640
5922
|
}
|
|
5641
5923
|
/**
|
|
@@ -5825,11 +6107,13 @@ function logBatchCost(runs) {
|
|
|
5825
6107
|
/**
|
|
5826
6108
|
* Classify one failed live run via `analyzeFailure` — same prompt as the
|
|
5827
6109
|
* deterministic path (Issue #47), fed the live transcript instead of the
|
|
5828
|
-
* vitest log. `auth
|
|
5829
|
-
* shared
|
|
5830
|
-
*
|
|
6110
|
+
* vitest log. `auth` is hoisted once by the caller; the diff comes from the
|
|
6111
|
+
* shared provider, already scoped to this spec's relatedPaths and truncated
|
|
6112
|
+
* (the live path used to feed the whole unscoped patch — in a monorepo that
|
|
6113
|
+
* ballooned the prompt with unrelated changes). Auth-unavailable /
|
|
6114
|
+
* no-failed-step degrade to `analysisSkipped` rather than throwing.
|
|
5831
6115
|
*/
|
|
5832
|
-
async function analyzeOneLiveFailure(r,
|
|
6116
|
+
async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts, cwd) {
|
|
5833
6117
|
const key = `${r.featureName}/${r.specName}`;
|
|
5834
6118
|
if (!auth.ok) return {
|
|
5835
6119
|
analysis: null,
|
|
@@ -5845,19 +6129,31 @@ async function analyzeOneLiveFailure(r, diff, baseRef, driftForSpec, auth, opts,
|
|
|
5845
6129
|
failureLogExcerpt: null,
|
|
5846
6130
|
diffExcerpt: null
|
|
5847
6131
|
};
|
|
6132
|
+
const specDiff = await diffProvider.forSpec({
|
|
6133
|
+
featureName: r.featureName,
|
|
6134
|
+
specName: r.specName
|
|
6135
|
+
});
|
|
6136
|
+
if (!specDiff.ok) return {
|
|
6137
|
+
analysis: null,
|
|
6138
|
+
analysisSkipped: specDiff.skip,
|
|
6139
|
+
failureLogExcerpt: excerpt,
|
|
6140
|
+
diffExcerpt: null
|
|
6141
|
+
};
|
|
6142
|
+
if (specDiff.error) info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
|
|
5848
6143
|
const outcome = await analyzeFailure({
|
|
5849
6144
|
liveTranscriptExcerpt: excerpt,
|
|
5850
6145
|
specYaml: r.specYaml,
|
|
5851
|
-
diffPatch:
|
|
5852
|
-
changedFiles:
|
|
5853
|
-
baseRef:
|
|
6146
|
+
diffPatch: specDiff.patch,
|
|
6147
|
+
changedFiles: specDiff.nameStatus,
|
|
6148
|
+
baseRef: specDiff.base.ref,
|
|
5854
6149
|
driftIssues: driftForSpec,
|
|
5855
6150
|
...opts.language ? { outputLanguage: opts.language } : {},
|
|
5856
6151
|
...opts.triageUserPrompt ? { triageUserPrompt: opts.triageUserPrompt } : {},
|
|
5857
6152
|
...opts.customPrompt ? { customPrompt: opts.customPrompt } : {}
|
|
5858
6153
|
}, {
|
|
5859
6154
|
...opts.model ? { model: opts.model } : {},
|
|
5860
|
-
cwd
|
|
6155
|
+
cwd,
|
|
6156
|
+
getFileDiff: specDiff.fileDiff
|
|
5861
6157
|
});
|
|
5862
6158
|
const pct = Math.round(outcome.analysis.confidence * 100);
|
|
5863
6159
|
const headline = outcome.analysis.headline.trim() || (outcome.analysis.reasoning.split("\n")[0] ?? "").trim();
|
|
@@ -5866,7 +6162,11 @@ async function analyzeOneLiveFailure(r, diff, baseRef, driftForSpec, auth, opts,
|
|
|
5866
6162
|
analysis: outcome.analysis,
|
|
5867
6163
|
analysisSkipped: null,
|
|
5868
6164
|
failureLogExcerpt: excerpt,
|
|
5869
|
-
diffExcerpt:
|
|
6165
|
+
diffExcerpt: specDiff.patch,
|
|
6166
|
+
analysisBase: {
|
|
6167
|
+
ref: specDiff.base.ref,
|
|
6168
|
+
sha: specDiff.base.sha
|
|
6169
|
+
}
|
|
5870
6170
|
};
|
|
5871
6171
|
}
|
|
5872
6172
|
function count(steps, target) {
|
|
@@ -8937,27 +9237,12 @@ function stripCodeFences(text) {
|
|
|
8937
9237
|
return m && m[1] !== void 0 ? m[1] : text;
|
|
8938
9238
|
}
|
|
8939
9239
|
//#endregion
|
|
8940
|
-
//#region src/run/errors.ts
|
|
8941
|
-
/**
|
|
8942
|
-
* Usage error (bad flag combination, broken profile, failed `git diff`, …)
|
|
8943
|
-
* thrown by the `run` pipeline and the helpers it calls, e.g.
|
|
8944
|
-
* `collectChangedSpecs`. `executeRun` never calls `process.exit`, so each
|
|
8945
|
-
* host maps this itself: the CLI action catches it and exits with
|
|
8946
|
-
* `exitCode`; the hub runner records it as a run-level error.
|
|
8947
|
-
*/
|
|
8948
|
-
var RunUsageError = class extends Error {
|
|
8949
|
-
exitCode = 2;
|
|
8950
|
-
constructor(message) {
|
|
8951
|
-
super(message);
|
|
8952
|
-
this.name = "RunUsageError";
|
|
8953
|
-
}
|
|
8954
|
-
};
|
|
8955
|
-
//#endregion
|
|
8956
9240
|
//#region src/cli/changed-specs.ts
|
|
8957
9241
|
/**
|
|
8958
|
-
* Filter specs to those affected by the git diff against the
|
|
8959
|
-
*
|
|
8960
|
-
* LLM new-file router (kept off here for predictable CI
|
|
9242
|
+
* Filter specs to those affected by the git diff against the `--changed
|
|
9243
|
+
* [base]` baseline. Powers `ccqa run --changed`; mirrors `ccqa drift
|
|
9244
|
+
* --changed` minus the LLM new-file router (kept off here for predictable CI
|
|
9245
|
+
* cost).
|
|
8961
9246
|
*
|
|
8962
9247
|
* A spec is "affected" when it has no `relatedPaths` (conservatively
|
|
8963
9248
|
* included), any changed file matches one of its `relatedPaths` globs, or it
|
|
@@ -8965,14 +9250,14 @@ var RunUsageError = class extends Error {
|
|
|
8965
9250
|
*/
|
|
8966
9251
|
async function collectChangedSpecs(specs, opts) {
|
|
8967
9252
|
const { cwd, base } = opts;
|
|
8968
|
-
const
|
|
9253
|
+
const resolved = await resolveAnalysisBase(base, "--changed", cwd);
|
|
8969
9254
|
let changed;
|
|
8970
9255
|
try {
|
|
8971
|
-
changed = await getChangedFiles(
|
|
9256
|
+
changed = await getChangedFiles(resolved.sha, cwd);
|
|
8972
9257
|
} catch (e) {
|
|
8973
|
-
throw new RunUsageError(`failed to run 'git diff' against ${
|
|
9258
|
+
throw new RunUsageError(`failed to run 'git diff' against ${resolved.ref}: ${e.message}`);
|
|
8974
9259
|
}
|
|
8975
|
-
meta("changed-base",
|
|
9260
|
+
meta("changed-base", `${resolved.ref} (${resolved.sha.slice(0, 12)})`);
|
|
8976
9261
|
meta("changed-files", changed.length);
|
|
8977
9262
|
return filterAffectedSpecs(specs, changed, cwd);
|
|
8978
9263
|
}
|
|
@@ -9036,6 +9321,27 @@ function dedupeSpecs(specs) {
|
|
|
9036
9321
|
async function executeRun(targets, opts) {
|
|
9037
9322
|
if (opts.changed && targets.length > 0) throw new RunUsageError("--changed and an explicit spec target cannot be combined");
|
|
9038
9323
|
const cwd = opts.cwd ?? process.cwd();
|
|
9324
|
+
const wantsLastGreen = opts.failureAnalysis === LAST_GREEN;
|
|
9325
|
+
const [head, fixedBase] = await Promise.all([getGitHead(cwd), opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
|
|
9326
|
+
const git = {
|
|
9327
|
+
head,
|
|
9328
|
+
base: wantsLastGreen ? {
|
|
9329
|
+
ref: LAST_GREEN,
|
|
9330
|
+
sha: null,
|
|
9331
|
+
source: "last-green"
|
|
9332
|
+
} : fixedBase
|
|
9333
|
+
};
|
|
9334
|
+
let diffProvider = null;
|
|
9335
|
+
if (fixedBase) {
|
|
9336
|
+
diffProvider = createDiffProvider({
|
|
9337
|
+
resolveBase: async () => ({
|
|
9338
|
+
ok: true,
|
|
9339
|
+
base: fixedBase
|
|
9340
|
+
}),
|
|
9341
|
+
cwd
|
|
9342
|
+
});
|
|
9343
|
+
meta("analysis-base", `${fixedBase.ref} (${fixedBase.sha.slice(0, 12)}, ${fixedBase.source})`);
|
|
9344
|
+
}
|
|
9039
9345
|
let projectForProfile;
|
|
9040
9346
|
try {
|
|
9041
9347
|
if (opts.profile !== void 0) {
|
|
@@ -9071,7 +9377,17 @@ async function executeRun(targets, opts) {
|
|
|
9071
9377
|
} catch {
|
|
9072
9378
|
hubCtx = null;
|
|
9073
9379
|
}
|
|
9074
|
-
|
|
9380
|
+
if (wantsLastGreen && hubCtx == null) throw new RunUsageError(`--failure-analysis=${LAST_GREEN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
|
|
9381
|
+
const ledgerHub = wantsLastGreen ? hubCtx : null;
|
|
9382
|
+
const [customPrompt, triageUserPrompt, ledgerEntries] = await Promise.all([
|
|
9383
|
+
fetchCustomPrompt(hubCtx),
|
|
9384
|
+
fetchTriageUserPrompt(hubCtx),
|
|
9385
|
+
ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null
|
|
9386
|
+
]);
|
|
9387
|
+
if (ledgerEntries) diffProvider = createDiffProvider({
|
|
9388
|
+
resolveBase: createLastGreenResolver(ledgerEntries, cwd),
|
|
9389
|
+
cwd
|
|
9390
|
+
});
|
|
9075
9391
|
const triageUserPromptHash = triageUserPrompt ? hashTriageUserPrompt(triageUserPrompt) : null;
|
|
9076
9392
|
const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
|
|
9077
9393
|
let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
|
|
@@ -9079,7 +9395,7 @@ async function executeRun(targets, opts) {
|
|
|
9079
9395
|
const before = specs.length;
|
|
9080
9396
|
specs = await collectChangedSpecs(specs, {
|
|
9081
9397
|
cwd,
|
|
9082
|
-
base: opts.
|
|
9398
|
+
base: opts.changed
|
|
9083
9399
|
});
|
|
9084
9400
|
meta("changed-scoped", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
|
|
9085
9401
|
}
|
|
@@ -9121,6 +9437,7 @@ async function executeRun(targets, opts) {
|
|
|
9121
9437
|
project: hubCtx.project,
|
|
9122
9438
|
...branch ? { branch } : {},
|
|
9123
9439
|
...opts.profile ? { profile: opts.profile } : {},
|
|
9440
|
+
...git.head ? { gitHead: git.head } : {},
|
|
9124
9441
|
kind: "run"
|
|
9125
9442
|
});
|
|
9126
9443
|
hubRunId = opened.id;
|
|
@@ -9141,11 +9458,7 @@ async function executeRun(targets, opts) {
|
|
|
9141
9458
|
warn(`hub: could not open incremental run (${errMessage(err)}); continuing with local report only`);
|
|
9142
9459
|
}
|
|
9143
9460
|
const incrementalReport = createIncrementalReport(reportDir, buildReportEnvelope({
|
|
9144
|
-
|
|
9145
|
-
ok: false,
|
|
9146
|
-
error: "diff not yet captured"
|
|
9147
|
-
},
|
|
9148
|
-
baseRef: null,
|
|
9461
|
+
git,
|
|
9149
9462
|
customPromptVersion: customPrompt?.customPromptVersion ?? null,
|
|
9150
9463
|
triageUserPromptHash,
|
|
9151
9464
|
opts
|
|
@@ -9177,13 +9490,12 @@ async function executeRun(targets, opts) {
|
|
|
9177
9490
|
...opts.language ? { language: opts.language } : {},
|
|
9178
9491
|
...opts.out && liveSpecs.length === 1 ? { out: opts.out } : {},
|
|
9179
9492
|
cwd,
|
|
9180
|
-
...opts.base ? { base: opts.base } : {},
|
|
9181
9493
|
reportDir,
|
|
9182
9494
|
...typeof opts.retry === "number" ? { retry: opts.retry } : {},
|
|
9183
9495
|
concurrency: opts.concurrency ?? 1,
|
|
9184
9496
|
...opts.profile ? { profile: opts.profile } : {},
|
|
9185
9497
|
...opts.driftAudit !== false ? { driftAudit: true } : {},
|
|
9186
|
-
|
|
9498
|
+
diffProvider,
|
|
9187
9499
|
hubContext: hubCtx,
|
|
9188
9500
|
customPrompt,
|
|
9189
9501
|
triageUserPrompt,
|
|
@@ -9195,7 +9507,7 @@ async function executeRun(targets, opts) {
|
|
|
9195
9507
|
if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
|
|
9196
9508
|
let report;
|
|
9197
9509
|
{
|
|
9198
|
-
const detReport = await analyzeDeterministicSummaries(det.summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt);
|
|
9510
|
+
const detReport = await analyzeDeterministicSummaries(det.summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt, diffProvider);
|
|
9199
9511
|
report = await writeUnifiedReport({
|
|
9200
9512
|
reportDir,
|
|
9201
9513
|
results: [
|
|
@@ -9203,8 +9515,7 @@ async function executeRun(targets, opts) {
|
|
|
9203
9515
|
...externalRows,
|
|
9204
9516
|
...live.reportResults
|
|
9205
9517
|
],
|
|
9206
|
-
|
|
9207
|
-
baseRef: detReport.baseRef,
|
|
9518
|
+
git,
|
|
9208
9519
|
customPromptVersion: detReport.customPromptVersion,
|
|
9209
9520
|
triageUserPromptHash,
|
|
9210
9521
|
opts
|
|
@@ -9213,8 +9524,7 @@ async function executeRun(targets, opts) {
|
|
|
9213
9524
|
if (hubRunId) {
|
|
9214
9525
|
const finalStatus = overallExitCode === 0 ? "passed" : "failed";
|
|
9215
9526
|
const reportMeta = buildReportEnvelope({
|
|
9216
|
-
|
|
9217
|
-
baseRef: detReport.baseRef,
|
|
9527
|
+
git,
|
|
9218
9528
|
customPromptVersion: detReport.customPromptVersion,
|
|
9219
9529
|
triageUserPromptHash,
|
|
9220
9530
|
opts
|
|
@@ -9412,24 +9722,15 @@ function failedSpec(s) {
|
|
|
9412
9722
|
* failure analysis when `--report` is on; degrades (no throw) when Claude
|
|
9413
9723
|
* auth or git diff aren't available. Caller writes the HTML / JSON.
|
|
9414
9724
|
*/
|
|
9415
|
-
async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt) {
|
|
9416
|
-
const failureAnalysisEnabled =
|
|
9725
|
+
async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt, diffProvider) {
|
|
9726
|
+
const failureAnalysisEnabled = diffProvider != null;
|
|
9417
9727
|
const driftAuditEnabled = failureAnalysisEnabled && opts.driftAudit !== false;
|
|
9418
|
-
const auth = failureAnalysisEnabled
|
|
9728
|
+
const auth = failureAnalysisEnabled ? driftAuthAvailable() : {
|
|
9419
9729
|
ok: false,
|
|
9420
9730
|
reason: "skipped by flags"
|
|
9421
9731
|
};
|
|
9422
9732
|
const failed = summaries.filter(failedSpec);
|
|
9423
9733
|
if (failureAnalysisEnabled && !auth.ok && failed.length > 0) info(`failure analysis skipped (${auth.reason})`);
|
|
9424
|
-
const baseRef = resolveBaseRef(opts.base);
|
|
9425
|
-
let diff = {
|
|
9426
|
-
ok: false,
|
|
9427
|
-
error: "diff not captured (no failures)"
|
|
9428
|
-
};
|
|
9429
|
-
if (failed.length > 0) {
|
|
9430
|
-
diff = await capturePrDiff(baseRef, cwd);
|
|
9431
|
-
if (!diff.ok) info(`drift-report: source diff unavailable (${diff.error}) — analyzing without diff context`);
|
|
9432
|
-
}
|
|
9433
9734
|
const tree = failed.length > 0 ? await listFeatureTree(cwd) : [];
|
|
9434
9735
|
const specInfoByKey = new Map(tree.flatMap((f) => f.specs.map((sp) => [`${f.featureName}/${sp.specName}`, sp])));
|
|
9435
9736
|
const findSpecInfo = (s) => specInfoByKey.get(`${s.featureName}/${s.specName}`) ?? null;
|
|
@@ -9456,9 +9757,9 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
|
|
|
9456
9757
|
onSpecStart: (t) => info(`drift audit: ${t.featureName}/${t.specName}`)
|
|
9457
9758
|
});
|
|
9458
9759
|
}
|
|
9459
|
-
const patchSections = diff.ok && diff.diff.patch.length > 0 ? splitPatchByFile(diff.diff.patch) : null;
|
|
9460
9760
|
const allBlocks = await loadAllBlocks(cwd);
|
|
9461
9761
|
let printedHeader = false;
|
|
9762
|
+
let warnedDiffUnavailable = false;
|
|
9462
9763
|
const results = [];
|
|
9463
9764
|
for (const s of summaries) {
|
|
9464
9765
|
const assertions = collectAssertions(s);
|
|
@@ -9493,14 +9794,23 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
|
|
|
9493
9794
|
});
|
|
9494
9795
|
continue;
|
|
9495
9796
|
}
|
|
9496
|
-
const
|
|
9497
|
-
|
|
9797
|
+
const specDiffResult = diffProvider ? await diffProvider.forSpec({
|
|
9798
|
+
featureName: s.featureName,
|
|
9799
|
+
specName: s.specName
|
|
9800
|
+
}) : null;
|
|
9801
|
+
const specDiff = specDiffResult?.ok ? specDiffResult : null;
|
|
9802
|
+
if (specDiff?.error && !warnedDiffUnavailable) {
|
|
9803
|
+
warnedDiffUnavailable = true;
|
|
9804
|
+
info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
|
|
9805
|
+
}
|
|
9806
|
+
const diffExcerpt = specDiff?.patch ?? null;
|
|
9498
9807
|
const driftResult = driftResults.find((r) => r.target.featureName === s.featureName && r.target.specName === s.specName);
|
|
9499
9808
|
const driftIssues = driftResult?.ok ? driftResult.issues : null;
|
|
9500
9809
|
const failureLog = buildFailureLog(s);
|
|
9501
9810
|
let analysis = null;
|
|
9502
9811
|
let analysisSkipped = null;
|
|
9503
|
-
if (!
|
|
9812
|
+
if (!specDiffResult) analysisSkipped = "skipped: --failure-analysis not enabled";
|
|
9813
|
+
else if (!specDiffResult.ok) analysisSkipped = specDiffResult.skip;
|
|
9504
9814
|
else if (!auth.ok) analysisSkipped = auth.reason;
|
|
9505
9815
|
else if (specYaml === null) analysisSkipped = "no spec.yaml found for this spec";
|
|
9506
9816
|
else {
|
|
@@ -9511,15 +9821,16 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
|
|
|
9511
9821
|
specYaml,
|
|
9512
9822
|
failureLog,
|
|
9513
9823
|
diffPatch: diffExcerpt,
|
|
9514
|
-
changedFiles:
|
|
9515
|
-
baseRef:
|
|
9824
|
+
changedFiles: specDiffResult.nameStatus,
|
|
9825
|
+
baseRef: specDiffResult.base.ref,
|
|
9516
9826
|
driftIssues,
|
|
9517
9827
|
...opts.language ? { outputLanguage: opts.language } : {},
|
|
9518
9828
|
...triageUserPrompt ? { triageUserPrompt } : {},
|
|
9519
9829
|
...customPrompt ? { customPrompt } : {}
|
|
9520
9830
|
}, {
|
|
9521
9831
|
...opts.model ? { model: opts.model } : {},
|
|
9522
|
-
cwd
|
|
9832
|
+
cwd,
|
|
9833
|
+
getFileDiff: specDiffResult.fileDiff
|
|
9523
9834
|
});
|
|
9524
9835
|
analysis = outcome.analysis;
|
|
9525
9836
|
if (!printedHeader) {
|
|
@@ -9537,6 +9848,10 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
|
|
|
9537
9848
|
status: "failed",
|
|
9538
9849
|
analysis,
|
|
9539
9850
|
analysisSkipped,
|
|
9851
|
+
...specDiff ? { analysisBase: {
|
|
9852
|
+
ref: specDiff.base.ref,
|
|
9853
|
+
sha: specDiff.base.sha
|
|
9854
|
+
} } : {},
|
|
9540
9855
|
driftIssues,
|
|
9541
9856
|
failureLogExcerpt: failureLog.length > 0 ? failureLog : null,
|
|
9542
9857
|
diffExcerpt,
|
|
@@ -9546,8 +9861,6 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
|
|
|
9546
9861
|
}
|
|
9547
9862
|
return {
|
|
9548
9863
|
results,
|
|
9549
|
-
diff,
|
|
9550
|
-
baseRef,
|
|
9551
9864
|
customPromptVersion: customPrompt?.customPromptVersion ?? null
|
|
9552
9865
|
};
|
|
9553
9866
|
}
|
|
@@ -9559,30 +9872,33 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
|
|
|
9559
9872
|
* final report.json stays byte-identical (existing e2e goldens compare it).
|
|
9560
9873
|
*/
|
|
9561
9874
|
function buildReportEnvelope(args) {
|
|
9562
|
-
const {
|
|
9875
|
+
const { git, customPromptVersion, triageUserPromptHash, opts } = args;
|
|
9563
9876
|
return {
|
|
9564
9877
|
schemaVersion: 1,
|
|
9565
9878
|
kind: "run",
|
|
9566
9879
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9567
9880
|
runId: process.env["GITHUB_RUN_ID"] ?? null,
|
|
9568
9881
|
git: {
|
|
9569
|
-
head:
|
|
9570
|
-
base:
|
|
9882
|
+
head: git.head,
|
|
9883
|
+
base: git.base?.ref ?? null,
|
|
9884
|
+
...git.base ? {
|
|
9885
|
+
baseSha: git.base.sha,
|
|
9886
|
+
baseSource: git.base.source
|
|
9887
|
+
} : {}
|
|
9571
9888
|
},
|
|
9572
9889
|
model: opts.model ?? null,
|
|
9573
9890
|
language: opts.language ?? null,
|
|
9574
|
-
promptVersion: "
|
|
9891
|
+
promptVersion: "5",
|
|
9575
9892
|
customPromptVersion,
|
|
9576
9893
|
...triageUserPromptHash !== null ? { triageUserPromptHash } : {}
|
|
9577
9894
|
};
|
|
9578
9895
|
}
|
|
9579
9896
|
/** Write the unified JSON (+ optional GitHub-annotation) report for one run. Returns the report data. */
|
|
9580
9897
|
async function writeUnifiedReport(args) {
|
|
9581
|
-
const { reportDir, results,
|
|
9898
|
+
const { reportDir, results, git, customPromptVersion, triageUserPromptHash, opts } = args;
|
|
9582
9899
|
const data = {
|
|
9583
9900
|
...buildReportEnvelope({
|
|
9584
|
-
|
|
9585
|
-
baseRef,
|
|
9901
|
+
git,
|
|
9586
9902
|
customPromptVersion,
|
|
9587
9903
|
triageUserPromptHash,
|
|
9588
9904
|
opts
|
|
@@ -9896,7 +10212,7 @@ function installTeardownSignalHandlers(teardown) {
|
|
|
9896
10212
|
}
|
|
9897
10213
|
//#endregion
|
|
9898
10214
|
//#region src/cli/run.ts
|
|
9899
|
-
const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs. Each spec's execution mode comes from its spec.yaml `mode:` field (default deterministic; set `mode: live` to have Claude drive agent-browser live per step). Deterministic specs replay the recorded test.spec.ts under vitest. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--changed", "Restrict execution to specs whose relatedPaths intersect the git diff against
|
|
10215
|
+
const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs. Each spec's execution mode comes from its spec.yaml `mode:` field (default deterministic; set `mode: live` to have Claude drive agent-browser live per step). Deterministic specs replay the recorded test.spec.ts under vitest. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--changed [base]", "Restrict execution to specs whose relatedPaths intersect the git diff against [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --changed=origin/main). Cannot be combined with an explicit spec id.").option("--failure-analysis [base]", "Classify each failure (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG) against the source diff since [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --failure-analysis=origin/main), or pass 'last-green' to diff each spec against the commit where it last passed (per-spec baselines from the hub; requires a hub connection). Off by default — no Claude calls without it.").option("--no-drift-audit", "With --failure-analysis: skip the spec↔code drift audit shown in the report.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
|
|
9900
10216
|
if (REPORT_FORMATS.includes(raw)) return raw;
|
|
9901
10217
|
throw new Error(`--format must be one of ${REPORT_FORMATS.join(" | ")}`);
|
|
9902
10218
|
}, "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--no-evidence", `(deterministic only) Skip step-boundary evidence capture (PNG + meta JSON written to ${DEFAULT_REPORT_DIR}/${EVIDENCE_SUBDIR}/ by default).`).option("--retry <n>", "(live only) Retry each failed step up to N more times before recording failure. Default 0.", (raw) => {
|
|
@@ -13390,6 +13706,11 @@ z.object({ error: z.object({
|
|
|
13390
13706
|
code: z.string(),
|
|
13391
13707
|
message: z.string()
|
|
13392
13708
|
}) });
|
|
13709
|
+
z.object({
|
|
13710
|
+
gitHead: z.string(),
|
|
13711
|
+
runId: z.string(),
|
|
13712
|
+
at: z.string()
|
|
13713
|
+
});
|
|
13393
13714
|
/**
|
|
13394
13715
|
* A triage-learning job. Grading failing specs in the hub UI produces the
|
|
13395
13716
|
* "actual cause" labels this reads; the job turns them into an improved
|
|
@@ -13505,6 +13826,7 @@ function createPushRunHandler(config) {
|
|
|
13505
13826
|
};
|
|
13506
13827
|
await config.storage.artifacts.putDir(run.id, dir);
|
|
13507
13828
|
await config.storage.runs.create(run);
|
|
13829
|
+
await updateLastGreenLedger(config.storage, run, report.results);
|
|
13508
13830
|
sendJson(ctx.res, 201, run);
|
|
13509
13831
|
} finally {
|
|
13510
13832
|
await rm(dir, {
|
|
@@ -13515,14 +13837,17 @@ function createPushRunHandler(config) {
|
|
|
13515
13837
|
};
|
|
13516
13838
|
}
|
|
13517
13839
|
/**
|
|
13518
|
-
* POST /api/v1/runs/open?project=&branch=&profile=&kind= — start a
|
|
13519
|
-
* run with no report yet. Unlike `POST /runs`, nothing is pushed up
|
|
13520
|
-
* the caller patches results in as they finish (`PATCH /runs/:id`), so
|
|
13521
|
-
* interrupted run still leaves a partial report on the hub instead of
|
|
13840
|
+
* POST /api/v1/runs/open?project=&branch=&profile=&kind=&gitHead= — start a
|
|
13841
|
+
* "running" run with no report yet. Unlike `POST /runs`, nothing is pushed up
|
|
13842
|
+
* front: the caller patches results in as they finish (`PATCH /runs/:id`), so
|
|
13843
|
+
* an interrupted run still leaves a partial report on the hub instead of
|
|
13844
|
+
* none. `gitHead` (optional) attributes the run to a commit from the start —
|
|
13845
|
+
* without it an interrupted run would never learn its commit.
|
|
13522
13846
|
*/
|
|
13523
13847
|
function createOpenRunHandler(config) {
|
|
13524
13848
|
return async (ctx) => {
|
|
13525
13849
|
const { project, branch, profile, kind } = parseRunScope(ctx);
|
|
13850
|
+
const gitHead = ctx.url.searchParams.get("gitHead");
|
|
13526
13851
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
13527
13852
|
const run = {
|
|
13528
13853
|
id: randomUUID(),
|
|
@@ -13537,7 +13862,7 @@ function createOpenRunHandler(config) {
|
|
|
13537
13862
|
passed: 0,
|
|
13538
13863
|
failed: 0
|
|
13539
13864
|
},
|
|
13540
|
-
gitHead: null,
|
|
13865
|
+
gitHead: gitHead || null,
|
|
13541
13866
|
promptVersion: "",
|
|
13542
13867
|
ciRunId: null,
|
|
13543
13868
|
reportCreatedAt: now,
|
|
@@ -13553,10 +13878,7 @@ const PatchRunRequestSchema = z.object({
|
|
|
13553
13878
|
done: z.boolean().optional(),
|
|
13554
13879
|
finalStatus: z.enum(["passed", "failed"]).optional(),
|
|
13555
13880
|
reportMeta: z.object({
|
|
13556
|
-
git:
|
|
13557
|
-
head: z.string().nullable(),
|
|
13558
|
-
base: z.string().nullable()
|
|
13559
|
-
}).partial().optional(),
|
|
13881
|
+
git: GitEnvelopeSchema.partial().optional(),
|
|
13560
13882
|
model: z.string().nullable().optional(),
|
|
13561
13883
|
language: z.string().nullable().optional(),
|
|
13562
13884
|
promptVersion: z.string().optional(),
|
|
@@ -13580,6 +13902,38 @@ function countSpecs(results) {
|
|
|
13580
13902
|
};
|
|
13581
13903
|
}
|
|
13582
13904
|
/**
|
|
13905
|
+
* Advance the last-green ledger for every passed spec of a terminal
|
|
13906
|
+
* `kind: "run"` run. Spec-level, not run-level: a run with one chronically
|
|
13907
|
+
* failing spec still moves the baseline of every spec that did pass.
|
|
13908
|
+
* Best-effort — a ledger failure must not fail the push; the ledger is an
|
|
13909
|
+
* accelerator for `--failure-analysis=last-green`, not part of the run
|
|
13910
|
+
* record. Runs without a branch or gitHead can't be placed in the ledger and
|
|
13911
|
+
* are skipped.
|
|
13912
|
+
*
|
|
13913
|
+
* Ordering caveat (known approximation): `at` is the run's reportCreatedAt —
|
|
13914
|
+
* open time for incremental runs, report time for immutable pushes. When two
|
|
13915
|
+
* runs on the same branch+profile overlap, "newest at wins" can pick either
|
|
13916
|
+
* of the two genuinely-green commits, since the hub has no git ancestry to
|
|
13917
|
+
* order them properly. Accepted: CI serializes per branch in practice, and a
|
|
13918
|
+
* baseline can only ever point at a commit where the spec really passed.
|
|
13919
|
+
*/
|
|
13920
|
+
async function updateLastGreenLedger(storage, run, results) {
|
|
13921
|
+
const { gitHead, branch } = run;
|
|
13922
|
+
if (run.kind !== "run" || !gitHead || !branch) return;
|
|
13923
|
+
const passed = results.filter((r) => r.status === "passed");
|
|
13924
|
+
if (passed.length === 0) return;
|
|
13925
|
+
const entries = Object.fromEntries(passed.map((r) => [`${r.feature}/${r.spec}`, {
|
|
13926
|
+
gitHead,
|
|
13927
|
+
runId: run.id,
|
|
13928
|
+
at: run.reportCreatedAt
|
|
13929
|
+
}]));
|
|
13930
|
+
try {
|
|
13931
|
+
await storage.lastGreen.merge(run.project, run.profile ?? "default", branch, entries);
|
|
13932
|
+
} catch (err) {
|
|
13933
|
+
console.error(`hub: last-green ledger update failed for run "${run.id}": ${err instanceof Error ? err.message : String(err)}`);
|
|
13934
|
+
}
|
|
13935
|
+
}
|
|
13936
|
+
/**
|
|
13583
13937
|
* PATCH /api/v1/runs/:id — incrementally add spec results (and evidence) to a
|
|
13584
13938
|
* "running" run. Once the run is terminal (`done: true` was sent, or it was
|
|
13585
13939
|
* pushed immutably via `POST /runs`), further patches are rejected with 409.
|
|
@@ -13601,6 +13955,7 @@ function createPatchRunHandler(config) {
|
|
|
13601
13955
|
if (!parsed.success) throw new HttpError(400, "invalid_body", `request body is invalid: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
|
|
13602
13956
|
const { rows, evidence, done, finalStatus, reportMeta } = parsed.data;
|
|
13603
13957
|
let specs = run.specs;
|
|
13958
|
+
let mergedResults = [];
|
|
13604
13959
|
await config.storage.artifacts.updateJsonFile(id, "report.json", (current) => {
|
|
13605
13960
|
const base = current ?? {
|
|
13606
13961
|
schemaVersion: 1,
|
|
@@ -13620,7 +13975,9 @@ function createPatchRunHandler(config) {
|
|
|
13620
13975
|
...base,
|
|
13621
13976
|
...reportMeta?.git ? { git: {
|
|
13622
13977
|
head: reportMeta.git.head ?? base.git.head,
|
|
13623
|
-
base: reportMeta.git.base ?? base.git.base
|
|
13978
|
+
base: reportMeta.git.base ?? base.git.base,
|
|
13979
|
+
baseSha: reportMeta.git.baseSha ?? base.git.baseSha ?? null,
|
|
13980
|
+
baseSource: reportMeta.git.baseSource ?? base.git.baseSource ?? null
|
|
13624
13981
|
} } : {},
|
|
13625
13982
|
...reportMeta?.model !== void 0 ? { model: reportMeta.model } : {},
|
|
13626
13983
|
...reportMeta?.language !== void 0 ? { language: reportMeta.language } : {},
|
|
@@ -13630,6 +13987,7 @@ function createPatchRunHandler(config) {
|
|
|
13630
13987
|
};
|
|
13631
13988
|
const merged = mergeResults(current?.results ?? [], rows);
|
|
13632
13989
|
specs = countSpecs(merged);
|
|
13990
|
+
mergedResults = merged;
|
|
13633
13991
|
return {
|
|
13634
13992
|
...envelope,
|
|
13635
13993
|
results: merged
|
|
@@ -13643,6 +14001,7 @@ function createPatchRunHandler(config) {
|
|
|
13643
14001
|
...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {}
|
|
13644
14002
|
} : { specs };
|
|
13645
14003
|
const updated = await config.storage.runs.update(id, patch);
|
|
14004
|
+
if (done) await updateLastGreenLedger(config.storage, updated, mergedResults);
|
|
13646
14005
|
sendJson(ctx.res, 200, updated);
|
|
13647
14006
|
};
|
|
13648
14007
|
}
|
|
@@ -13759,9 +14118,11 @@ function parseRunScope(ctx) {
|
|
|
13759
14118
|
};
|
|
13760
14119
|
}
|
|
13761
14120
|
/**
|
|
13762
|
-
* A branch is a free-form label (e.g. `feature/foo`),
|
|
13763
|
-
*
|
|
13764
|
-
*
|
|
14121
|
+
* A branch is a free-form label (e.g. `feature/foo`), so `/` is allowed —
|
|
14122
|
+
* only length is bounded (a sanity cap; the last-green ledger separately
|
|
14123
|
+
* hash-truncates long percent-encoded names into a safe filename, see
|
|
14124
|
+
* paths.ts). Run records store it verbatim. null when the client didn't
|
|
14125
|
+
* send one. Exported for the last-green handler.
|
|
13765
14126
|
*/
|
|
13766
14127
|
function requireBranch(raw) {
|
|
13767
14128
|
if (raw === null || raw === "") return null;
|
|
@@ -14014,6 +14375,32 @@ function createListProfilesHandler(storage) {
|
|
|
14014
14375
|
};
|
|
14015
14376
|
}
|
|
14016
14377
|
//#endregion
|
|
14378
|
+
//#region src/hub/api/handlers/last-green.ts
|
|
14379
|
+
/**
|
|
14380
|
+
* GET /api/v1/projects/:project/last-green?profile=&branch=&fallbackBranch=
|
|
14381
|
+
*
|
|
14382
|
+
* Returns the last-green ledger entries for one project/profile, keyed by
|
|
14383
|
+
* "feature/spec". `branch` is the caller's current branch; `fallbackBranch`
|
|
14384
|
+
* (optional, typically the default branch) is overlaid *under* it, so a PR
|
|
14385
|
+
* branch with no greens of its own still inherits the default branch's
|
|
14386
|
+
* baselines while its own greens take precedence. One round trip serves the
|
|
14387
|
+
* whole run.
|
|
14388
|
+
*/
|
|
14389
|
+
function createGetLastGreenHandler(storage) {
|
|
14390
|
+
return async (ctx) => {
|
|
14391
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
14392
|
+
const profile = requireSafeSegment(ctx.url.searchParams.get("profile") ?? "default", "profile");
|
|
14393
|
+
const branch = requireBranch(ctx.url.searchParams.get("branch"));
|
|
14394
|
+
if (!branch) throw new HttpError(400, "missing_param", "branch query parameter is required");
|
|
14395
|
+
const fallbackBranch = requireBranch(ctx.url.searchParams.get("fallbackBranch"));
|
|
14396
|
+
const [primary, fallback] = await Promise.all([storage.lastGreen.get(project, profile, branch), fallbackBranch && fallbackBranch !== branch ? storage.lastGreen.get(project, profile, fallbackBranch) : Promise.resolve({})]);
|
|
14397
|
+
sendJson(ctx.res, 200, { entries: {
|
|
14398
|
+
...fallback,
|
|
14399
|
+
...primary
|
|
14400
|
+
} });
|
|
14401
|
+
};
|
|
14402
|
+
}
|
|
14403
|
+
//#endregion
|
|
14017
14404
|
//#region src/hub/api/handlers/prompts.ts
|
|
14018
14405
|
const MAX_PROMPT_BODY_BYTES = 256 * 1024;
|
|
14019
14406
|
/** Validate the `:project` route param (prompts are project-scoped, not per-profile). */
|
|
@@ -18022,7 +18409,7 @@ function createLearningWorker(deps) {
|
|
|
18022
18409
|
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
18023
18410
|
const customPrompt = {
|
|
18024
18411
|
schemaVersion: 1,
|
|
18025
|
-
basePromptVersion: "
|
|
18412
|
+
basePromptVersion: "5",
|
|
18026
18413
|
customPromptVersion: `${generatedAt}-c${cases.length}`,
|
|
18027
18414
|
generatedAt,
|
|
18028
18415
|
guidance
|
|
@@ -18141,6 +18528,7 @@ function registerRoutes(router, config, queue) {
|
|
|
18141
18528
|
router.put("/api/v1/runs/:id/triage/actual-causes", createImportActualCausesHandler(storage));
|
|
18142
18529
|
router.get("/api/v1/projects", createListProjectsHandler(storage));
|
|
18143
18530
|
router.get("/api/v1/projects/:project/profiles", createListProfilesHandler(storage));
|
|
18531
|
+
router.get("/api/v1/projects/:project/last-green", createGetLastGreenHandler(storage));
|
|
18144
18532
|
const sessionConfig = {
|
|
18145
18533
|
store: storage.sessions,
|
|
18146
18534
|
encryptionKey: config.encryptionKey
|
|
@@ -18373,6 +18761,10 @@ function perspectivesKindDir(root) {
|
|
|
18373
18761
|
function perspectivesPath(root, project) {
|
|
18374
18762
|
return join(perspectivesKindDir(root), `${project}.json`);
|
|
18375
18763
|
}
|
|
18764
|
+
function lastGreenPath(root, project, profile, branch) {
|
|
18765
|
+
const encoded = encodeURIComponent(branch);
|
|
18766
|
+
return join(root, "last-green", project, profile, `${encoded.length <= 200 ? encoded : `${encoded.slice(0, 64)}-${createHash("sha256").update(branch).digest("hex").slice(0, 32)}`}.json`);
|
|
18767
|
+
}
|
|
18376
18768
|
//#endregion
|
|
18377
18769
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
18378
18770
|
/**
|
|
@@ -18460,6 +18852,25 @@ function createFileJobStore(root) {
|
|
|
18460
18852
|
};
|
|
18461
18853
|
}
|
|
18462
18854
|
//#endregion
|
|
18855
|
+
//#region src/hub/core/storage/file/last-green-store.ts
|
|
18856
|
+
function createFileLastGreenStore(root) {
|
|
18857
|
+
return {
|
|
18858
|
+
async get(project, profile, branch) {
|
|
18859
|
+
return await readJson(lastGreenPath(root, project, profile, branch)) ?? {};
|
|
18860
|
+
},
|
|
18861
|
+
async merge(project, profile, branch, entries) {
|
|
18862
|
+
await updateJson(lastGreenPath(root, project, profile, branch), (current) => {
|
|
18863
|
+
const out = { ...current ?? {} };
|
|
18864
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
18865
|
+
const prev = out[key];
|
|
18866
|
+
if (!prev || prev.at <= entry.at) out[key] = entry;
|
|
18867
|
+
}
|
|
18868
|
+
return out;
|
|
18869
|
+
});
|
|
18870
|
+
}
|
|
18871
|
+
};
|
|
18872
|
+
}
|
|
18873
|
+
//#endregion
|
|
18463
18874
|
//#region src/hub/core/storage/file/perspectives-store.ts
|
|
18464
18875
|
/**
|
|
18465
18876
|
* Defense-in-depth path validation: the HTTP layer already checks the project
|
|
@@ -18713,7 +19124,8 @@ function createFileHubStorage(dataDir) {
|
|
|
18713
19124
|
triage: createFileTriageStore(dataDir),
|
|
18714
19125
|
prompts: createFilePromptStore(dataDir),
|
|
18715
19126
|
perspectives: createFilePerspectivesStore(dataDir),
|
|
18716
|
-
jobs: createFileJobStore(dataDir)
|
|
19127
|
+
jobs: createFileJobStore(dataDir),
|
|
19128
|
+
lastGreen: createFileLastGreenStore(dataDir)
|
|
18717
19129
|
};
|
|
18718
19130
|
}
|
|
18719
19131
|
//#endregion
|