snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
246
.claude/hooks/lib/validation.mjs
Normal file
246
.claude/hooks/lib/validation.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { getBaseBranch, getWorktreeChangeSummary, getWorktreePaths } from "./git.mjs";
|
||||
import { bash, exec } from "./process.mjs";
|
||||
import { loadConfig, validationSteps } from "./config.mjs";
|
||||
import { appendProgress } from "./progress-log.mjs";
|
||||
|
||||
// `only` narrows to a single worktree when the caller already knows it
|
||||
// (validate-on-stop scoping to the stopping agent's own task worktree);
|
||||
// VALIDATE_WORKTREE is the test override. Either falls back to all session
|
||||
// worktrees when the path is gone.
|
||||
export function getActiveWorktrees(ctx, only = "") {
|
||||
const narrowed = only || process.env.VALIDATE_WORKTREE || "";
|
||||
if (narrowed && existsSync(narrowed)) return [narrowed];
|
||||
return getWorktreePaths().filter((p) => p.startsWith(ctx.worktreeBase + "/"));
|
||||
}
|
||||
|
||||
// Pure query — never exits. An empty list with a non-empty skipReason means
|
||||
// there is nothing to validate.
|
||||
function getWorktreesToValidate(ctx, { skipAdrOnly = false, only = "", base = "" } = {}) {
|
||||
if (!existsSync(ctx.repo)) {
|
||||
return { worktrees: [], skipReason: "cd_failed" };
|
||||
}
|
||||
const active = getActiveWorktrees(ctx, only);
|
||||
if (active.length === 0) {
|
||||
return { worktrees: [], skipReason: "no_active_worktree" };
|
||||
}
|
||||
// Compare against the branch the worktree forked from (the session branch,
|
||||
// passed by validate-on-stop) so the change set is the ticket's OWN work, not
|
||||
// everything since the repo's checked-out base branch — which would over- or
|
||||
// under-report a worktree's delta whenever the base branch has diverged from
|
||||
// the session branch. Falls back to the repo base branch when no override.
|
||||
const effectiveBase = base || getBaseBranch();
|
||||
const isAdrOnly = (files) => files.length > 0 && files.every((f) => f.startsWith("adr/"));
|
||||
const worktrees = active.filter((wt) => {
|
||||
const { dirty, changedFiles } = getWorktreeChangeSummary(wt, effectiveBase);
|
||||
if (!dirty) {
|
||||
ctx.log(`SKIP wt=${wt} (no changes)`);
|
||||
return false;
|
||||
}
|
||||
if (skipAdrOnly && isAdrOnly(changedFiles)) {
|
||||
ctx.log(`SKIP wt=${wt} (adr-only)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { worktrees, skipReason: worktrees.length === 0 ? "no_dirty_worktree" : "" };
|
||||
}
|
||||
|
||||
const tailLines = (text, n) => text.split("\n").slice(-n).join("\n");
|
||||
|
||||
// Last `n` lines of a file, "" if it can't be read.
|
||||
const tailFile = (path, n) => {
|
||||
try {
|
||||
return tailLines(readFileSync(path, "utf8"), n);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Best-effort cleanup — a leftover temp file must never break validation.
|
||||
const tryUnlink = (path) => {
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch {
|
||||
/* swallow error */
|
||||
}
|
||||
};
|
||||
|
||||
function runVitest(wt, configFile, projects = [], changedSince = "") {
|
||||
const projectTag = projects.length ? `-${projects.join("-")}` : "";
|
||||
const out = join(tmpdir(), `vitest-${basename(configFile)}${projectTag}-${process.pid}.out`);
|
||||
const projectFlags = projects.map((p) => `--project ${p}`).join(" ");
|
||||
// Scope to tests related to THIS worktree's diff since `changedSince` (the session
|
||||
// branch it forked from), so a ticket runs only its own affected tests, not the whole
|
||||
// suite. Pass the ref EXPLICITLY: the ticket's work is committed by stop time,
|
||||
// so a bare `--changed` (uncommitted-only) would find nothing and run zero tests (false
|
||||
// green). Empty ref -> full run (safe fallback). The end-of-feature smoke re-runs the
|
||||
// full suite on the integrated session branch, catching anything the module graph missed.
|
||||
const changedFlag = changedSince ? `--changed ${changedSince}` : "";
|
||||
const r = bash(
|
||||
`CI=true timeout 180 npx vitest run --config ${configFile} ${projectFlags} ${changedFlag} > "${out}" 2>&1`,
|
||||
{ cwd: wt },
|
||||
);
|
||||
const output = tailFile(out, 40);
|
||||
tryUnlink(out);
|
||||
return { status: r.status, output, timedOut: r.status === 124 };
|
||||
}
|
||||
|
||||
// Files this worktree changed that lint should check: the config `extensions`,
|
||||
// restricted to paths that still exist on disk. A deleted / renamed-away file
|
||||
// must not be handed to eslint (it would error "No files matching"). Pure —
|
||||
// unit-tested independently of the shell-out.
|
||||
export function scopedLintFiles(changedFiles, cwd, extensions) {
|
||||
return changedFiles.filter(
|
||||
(f) => extensions.some((e) => f.endsWith(e)) && existsSync(join(cwd, f)),
|
||||
);
|
||||
}
|
||||
|
||||
// Run eslint on ONLY this worktree's diff since `base` (the session branch it
|
||||
// forked from), mirroring the vitest `--changed` scoping: a ticket is judged on
|
||||
// its own changed files, never the whole repo — which also sidesteps pre-existing
|
||||
// lint noise in unrelated / generated files. Zero matching files -> nothing to
|
||||
// lint (ok). `base` empty -> repo base branch, matching getWorktreesToValidate.
|
||||
// `command` is the eslint invocation prefix (e.g. `npx eslint`); files are
|
||||
// appended here, so the config command must NOT carry its own file glob.
|
||||
function runEslintScoped(cwd, base, command, extensions, tail) {
|
||||
const { changedFiles } = getWorktreeChangeSummary(cwd, base || getBaseBranch());
|
||||
const files = scopedLintFiles(changedFiles, cwd, extensions);
|
||||
if (files.length === 0) return { ok: true };
|
||||
const out = join(tmpdir(), `eslint-${process.pid}.out`);
|
||||
const args = files.map((f) => `'${f.replace(/'/g, `'\\''`)}'`).join(" ");
|
||||
const r = bash(`${command} ${args} > "${out}" 2>&1`, { cwd });
|
||||
const output = tailFile(out, tail);
|
||||
tryUnlink(out);
|
||||
return r.status === 0 ? { ok: true } : { ok: false, output };
|
||||
}
|
||||
|
||||
// Per-kind tail length for failure output (mechanical, not a project fact).
|
||||
const TAIL_LINES = { format: 15, typecheck: 20, lint: 30, unit: 40, e2e: 50 };
|
||||
|
||||
// A step is skipped when its `condition` is not met. `pathExists` gates on a
|
||||
// path under the repo (e.g. the supabase/functions unit-fn gate); `modeNot`
|
||||
// skips when MODE equals the given value (e.g. e2e in demo mode).
|
||||
function stepSkipped(ctx, step) {
|
||||
const c = step.condition;
|
||||
if (!c) return false;
|
||||
if (c.pathExists && !existsSync(join(ctx.repo, c.pathExists))) {
|
||||
ctx.log(`SKIP ${step.id} (no ${c.pathExists})`);
|
||||
return true;
|
||||
}
|
||||
if (c.modeNot && (process.env.MODE || "demo") === c.modeNot) {
|
||||
ctx.log(`${step.id} skipped (${c.modeNot} mode)`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run ONE config-declared step in `cwd`. Returns { ok } or { ok:false, output }.
|
||||
// The step id is the caller's fail-report label, so config ids (prettier,
|
||||
// typecheck, unit-app, unit-fn, e2e) ARE the reported step names.
|
||||
function runStep(ctx, step, { cwd, base }) {
|
||||
const tail = TAIL_LINES[step.kind] ?? 40;
|
||||
|
||||
if (step.runner === "vitest") {
|
||||
const r = runVitest(cwd, step.config, step.projects, step.changedScoped ? base : "");
|
||||
if (r.timedOut) return { ok: false, output: "TIMEOUT (>180s) -- vitest did not exit. Tests may be hanging." };
|
||||
return r.status === 0 ? { ok: true } : { ok: false, output: r.output };
|
||||
}
|
||||
|
||||
if (step.kind === "lint" && step.changedScoped) {
|
||||
const exts = step.extensions ?? [".ts", ".tsx", ".mjs"];
|
||||
return runEslintScoped(cwd, base, step.command, exts, tail);
|
||||
}
|
||||
|
||||
const r = bash(`${step.command} 2>&1`, { cwd });
|
||||
if (r.status === 0) {
|
||||
if (step.kind === "format" && step.autoCommit) {
|
||||
if (exec("git", ["-C", cwd, "diff", "--quiet"]).status !== 0) {
|
||||
exec("git", ["-C", cwd, "add", "-A"]);
|
||||
exec("git", ["-C", cwd, "commit", "-m", `style(${basename(cwd)}): auto-apply prettier`]);
|
||||
ctx.log(`auto-prettier committed wt=${cwd}`);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
const detail =
|
||||
step.kind === "format"
|
||||
? "Prettier could not format one or more files (likely a syntax error). Fix the issue and commit.\n" +
|
||||
tailLines(r.stdout, tail)
|
||||
: tailLines(r.stdout, tail);
|
||||
return { ok: false, output: detail };
|
||||
}
|
||||
|
||||
// The full validation chain, run by validate-on-stop.mjs on SubagentStop (after
|
||||
// every developer stop). Steps are declared in harness.config.json's
|
||||
// `validation.steps` (single source of truth, shared with bash-guard) and run in
|
||||
// config order: steps without `cwd:"repo"` run per dirty worktree (fail-fast);
|
||||
// `cwd:"repo"` steps (e2e) run once in the repo after the per-worktree pass.
|
||||
// VALIDATE_DRY_RUN=1 skips everything, =fail simulates a failure. A malformed
|
||||
// config fails closed. Returns { ok:true, skipReason? } or { ok:false, step, output }.
|
||||
export function runValidationSteps(ctx, { worktree = "", base = "" } = {}) {
|
||||
if (process.env.VALIDATE_DRY_RUN === "1") {
|
||||
ctx.log("DRY_RUN=1, skipping validation");
|
||||
return { ok: true, skipReason: "dry_run" };
|
||||
}
|
||||
if (process.env.VALIDATE_DRY_RUN === "fail") {
|
||||
ctx.log("DRY_RUN=fail, simulating a failure");
|
||||
return { ok: false, step: "dry-run", output: "Validation failed (simulated)." };
|
||||
}
|
||||
|
||||
let steps;
|
||||
try {
|
||||
steps = validationSteps(loadConfig(ctx.repo));
|
||||
} catch (e) {
|
||||
// Fail closed: a malformed config must block the stop, not silently pass.
|
||||
return { ok: false, step: "config", output: `${e.message}\n` };
|
||||
}
|
||||
|
||||
const { worktrees, skipReason } = getWorktreesToValidate(ctx, {
|
||||
skipAdrOnly: true,
|
||||
only: worktree,
|
||||
base,
|
||||
});
|
||||
if (skipReason) return { ok: true, skipReason };
|
||||
|
||||
const perWorktree = steps.filter((s) => s.cwd !== "repo");
|
||||
const repoLevel = steps.filter((s) => s.cwd === "repo");
|
||||
|
||||
// Enrich the #technical-harness progress log with each validation step as it runs,
|
||||
// so the otherwise-silent multi-minute chain (typecheck, lint, vitest, e2e) shows up
|
||||
// in a `tail -f` / the board / a Monitor. Inert (no-op) on a non-technical run: the
|
||||
// log does not exist there, so appendProgress writes nothing and creates nothing.
|
||||
const progress = (line) => appendProgress(ctx.sessionDir, line);
|
||||
|
||||
for (const wt of worktrees) {
|
||||
const label = basename(wt);
|
||||
for (const step of perWorktree) {
|
||||
if (stepSkipped(ctx, step)) continue;
|
||||
progress(`[validate:${label}] ${step.id}…`);
|
||||
const r = runStep(ctx, step, { cwd: wt, base });
|
||||
if (!r.ok) {
|
||||
progress(`[validate:${label}] ${step.id} FAILED`);
|
||||
ctx.log(`FAIL step=${step.id} wt=${wt}\n${r.output}`);
|
||||
return { ok: false, step: step.id, output: `=== ${step.id} failed in ${wt} ===\n${r.output}\n` };
|
||||
}
|
||||
}
|
||||
progress(`[validate:${label}] checks passed`);
|
||||
ctx.log(`OK wt=${wt}`);
|
||||
}
|
||||
|
||||
for (const step of repoLevel) {
|
||||
if (stepSkipped(ctx, step)) continue;
|
||||
progress(`[validate:repo] ${step.id}…`);
|
||||
const r = runStep(ctx, step, { cwd: ctx.repo, base });
|
||||
if (!r.ok) {
|
||||
progress(`[validate:repo] ${step.id} FAILED`);
|
||||
ctx.log(`FAIL step=${step.id} exit`);
|
||||
return { ok: false, step: step.id, output: r.output + "\n" };
|
||||
}
|
||||
ctx.log(`${step.id} OK`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user