diff --git a/scripts/work/state-sync-guard.mjs b/scripts/work/state-sync-guard.mjs new file mode 100644 index 0000000..a62404d --- /dev/null +++ b/scripts/work/state-sync-guard.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Pre-commit guard: refuses the commit if docs/work/_state.json is staged + * but is not byte-identical to what `pnpm work rebuild-state` would emit + * given the current markdown content of docs/work/. + * + * Run from .husky/pre-commit AFTER lint-staged and AFTER the conditional + * rebuild-state + re-stage step. By that point the staged _state.json + * should already match the rebuild output. This script is the safety net + * for the case where someone hand-edits _state.json without going through + * rebuild-state. + * + * Exit codes: + * 0 — _state.json is in sync (or not staged at all) + * 1 — _state.json is staged but out of sync + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; +import { buildState } from "./state-builder.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const WORK_ROOT = path.join(REPO_ROOT, "docs", "work"); +const STATE_FILE = path.join(WORK_ROOT, "_state.json"); + +function stagedFiles() { + const out = execSync("git diff --cached --name-only", { cwd: REPO_ROOT, encoding: "utf8" }); + return out.split("\n").filter(Boolean); +} + +function main() { + const staged = stagedFiles(); + const stateRel = path.relative(REPO_ROOT, STATE_FILE); + if (!staged.includes(stateRel) && !staged.some((f) => f.startsWith("docs/work/") && f.endsWith(".md"))) { + process.exit(0); + } + if (!fs.existsSync(STATE_FILE)) { + console.error("✗ state-sync-guard: docs/work/_state.json missing. Run `pnpm work rebuild-state`."); + process.exit(1); + } + const onDisk = fs.readFileSync(STATE_FILE, "utf8"); + const fresh = JSON.stringify(buildState(WORK_ROOT), null, 2) + "\n"; + // The `updated_at` timestamp will always differ. Strip it from both sides + // before comparing. + const stripUpdatedAt = (s) => s.replace(/"updated_at":\s*"[^"]+",?\s*\n?/, ""); + if (stripUpdatedAt(onDisk) === stripUpdatedAt(fresh)) { + process.exit(0); + } + console.error("✗ state-sync-guard: docs/work/_state.json is out of sync with markdown."); + console.error(" Run: pnpm work rebuild-state"); + console.error(" Then: git add docs/work/_state.json"); + process.exit(1); +} + +main(); diff --git a/scripts/work/state-sync-guard.test.mjs b/scripts/work/state-sync-guard.test.mjs new file mode 100644 index 0000000..aa16f1c --- /dev/null +++ b/scripts/work/state-sync-guard.test.mjs @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const GUARD = path.join(__dirname, "state-sync-guard.mjs"); + +describe("state-sync-guard smoke", () => { + it("exits 0 when run against the repo's current state (since main is in sync)", () => { + // Run the guard; if main's _state.json drifts, this test will fail and tell us. + const result = execSync(`node "${GUARD}"`, { encoding: "utf8" }); + // No assertion on output; the exit code is 0 if we got here without throwing. + expect(true).toBe(true); + }); +});