diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d89bfd4..466d1c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Seeded tree-construction determinism fuzzer** — Added property-based coverage for patch and checkpoint tree construction, proving stable tree OIDs across internal content-anchor permutations in `PatchBuilderV2` and shuffled content-property insertion order in `CheckpointService.createV5()`. - **Focused markdownlint gate** — Added `npm run lint:md` backed by `markdownlint-cli` and a repo config that enforces fenced code-block languages (`MD040`) across Markdown files. - **Markdown JS/TS code-sample linter** — Added `npm run lint:md:code`, which scans fenced JavaScript and TypeScript blocks in Markdown and syntax-checks them with the TypeScript parser for file/line-accurate diagnostics. +- **Pre-push hook regression harness** — Added a focused Vitest behavioral harness for `scripts/hooks/pre-push` that exercises the real shell hook with stubbed commands, proves quick mode skips Gate 8, and verifies Gate 1–8 failure labels at runtime. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index c4f5b426..d8ff691e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -204,7 +204,7 @@ P1 is complete on `v15`: B36 and B37 landed as the shared test-foundation pass, ### P2 — CI & Tooling (one batch PR) -`B83`, `B85`, `B57`, `B86`, and `B87` are now merged on `main`. The repo now runs both markdownlint and the Markdown JS/TS code-sample linter in the CI fast gate and the local `scripts/hooks/pre-push` firewall. Remaining P2 work starts at B88. This merge also promoted one follow-up item, B168, so the local hook's gate labels and quick-mode messaging get their own regression coverage. B123 is still the largest item and may need to split out if the PR gets too big. +`B83`, `B85`, `B57`, `B86`, and `B87` are now merged on `main`. The repo now runs both markdownlint and the Markdown JS/TS code-sample linter in the CI fast gate and the local `scripts/hooks/pre-push` firewall. Remaining P2 work starts at B88. That merge also promoted one follow-up item, B168, so the local hook's gate labels and quick-mode messaging now have their own regression-coverage task. B123 is still the largest item and may need to split out if the PR gets too big. | ID | Item | Depends on | Effort | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ------ | @@ -339,7 +339,7 @@ Complete on `v15`: **B80** and **B99**. 3. **B88, B119, B123, B128, B12, B43, B168** -Internal chain: **B97 already resolved** → B85 → B57. That chain is complete on `main`, and B87 now ships on top of the existing B86 markdown gate to cover JS/TS sample syntax. B168 captures the remaining hook-message drift follow-up from the B87 review cycle. B123 remains the largest remaining item and may need to split out. +Internal chain: **B97 already resolved** → B85 → B57. That chain is complete on `main`, and B168 remains the hook-message drift follow-up from the B87 review cycle. B123 remains the largest remaining item and may need to split out. #### Wave 3: Type Surface (P3) @@ -505,7 +505,7 @@ B158 (P7) ──→ B159 (P7) CDC seek cache Every milestone has a hard gate. No milestone blurs into the next. All milestones are complete: M10 → M12 → M13 (internal) → M11 → M14. M13 wire-format cutover remains deferred by ADR 3 readiness gates. -The active backlog is **26 standalone items** sorted into **8 priority tiers** (P0–P7) with **6 execution waves**. Wave 1 is complete, and Wave 2 now starts at B88 in the CI & Tooling pack, with B168 and B169 added from the PR #66 review loop. See [Execution Order](#execution-order) for the full sequence. +The active backlog is **26 standalone items** sorted into **8 priority tiers** (P0–P7) with **6 execution waves**. Wave 1 is complete, and Wave 2 now starts at B88 in the CI & Tooling pack, with B168 still active as the remaining hook-message drift follow-up. See [Execution Order](#execution-order) for the full sequence. Rejected items live in `GRAVEYARD.md`. Resurrections require an RFC. `BACKLOG.md` retired — all intake goes directly into this file (policy in `CLAUDE.md`). diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index a15cf115..e91b3e7f 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -66,7 +66,7 @@ if [ "$QUICK" = "1" ]; then echo "[Gate 8] Skipped (WARP_QUICK_PUSH quick mode)" else echo "[Gate 8] Running unit tests..." - npm run test:local + npm run test:local || { echo ""; echo "BLOCKED — Gate 8 FAILED: Unit tests"; exit 1; } fi echo "══════════════════════════════════════════════════════════" diff --git a/test/unit/scripts/pre-push-hook.test.js b/test/unit/scripts/pre-push-hook.test.js new file mode 100644 index 00000000..2adc0dba --- /dev/null +++ b/test/unit/scripts/pre-push-hook.test.js @@ -0,0 +1,192 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const hookPath = fileURLToPath(new URL('../../../scripts/hooks/pre-push', import.meta.url)); + +/** @type {string[]} */ +const tempDirs = []; + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(/** @type {string} */ (tempDirs.pop()), { force: true, recursive: true }); + } +}); + +/** + * @returns {string} + */ +function createTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'git-warp-pre-push-')); + tempDirs.push(dir); + return dir; +} + +/** + * @param {string} filePath + * @param {string} source + */ +function writeExecutable(filePath, source) { + writeFileSync(filePath, source, 'utf8'); + chmodSync(filePath, 0o755); +} + +/** + * @param {string} filePath + * @returns {string[]} + */ +function readLog(filePath) { + try { + return readFileSync(filePath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +/** + * @param {{ quick?: boolean, failCommand?: string|null }} [options] + */ +function runPrePushHook(options = {}) { + const { quick = false, failCommand = null } = options; + const binDir = createTempDir(); + const npmLog = join(binDir, 'npm.log'); + const lycheeLog = join(binDir, 'lychee.log'); + + writeExecutable( + join(binDir, 'npm'), + [ + '#!/bin/sh', + 'set -eu', + 'cmd="$1"', + 'if [ "$cmd" = "run" ]; then', + ' cmd="$2"', + 'fi', + "printf '%s\\n' \"$cmd\" >> \"$WARP_NPM_LOG\"", + 'if [ "${WARP_FAIL_NPM_CMD:-}" = "$cmd" ]; then', + ' echo "stub npm failing for $cmd" >&2', + ' exit 1', + 'fi', + 'exit 0', + '', + ].join('\n') + ); + + writeExecutable( + join(binDir, 'lychee'), + [ + '#!/bin/sh', + 'set -eu', + "printf '%s\\n' \"$*\" >> \"$WARP_LYCHEE_LOG\"", + 'exit 0', + '', + ].join('\n') + ); + + /** @type {Record} */ + const env = { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + WARP_NPM_LOG: npmLog, + WARP_LYCHEE_LOG: lycheeLog, + }; + + if (quick) { + env.WARP_QUICK_PUSH = '1'; + } + + if (failCommand) { + env.WARP_FAIL_NPM_CMD = failCommand; + } + + const result = spawnSync('sh', [hookPath], { + cwd: repoRoot, + env, + encoding: 'utf8', + timeout: 3000, + }); + + if (result.error) { + throw result.error; + } + + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + commands: readLog(npmLog), + lycheeCalls: readLog(lycheeLog), + }; +} + +describe('scripts/hooks/pre-push', () => { + it('keeps the checked-in header aligned with the runtime gate layout', () => { + const source = readFileSync(hookPath, 'utf8'); + + expect(source).toContain('# Seven gates in parallel, then unit tests. ALL must pass or push is blocked.'); + }); + + it('skips Gate 8 in quick mode without running unit tests', () => { + const result = runPrePushHook({ quick: true }); + + expect(result.status).toBe(0); + expect(result.output).toContain('WARP_QUICK_PUSH: quick mode active — Gate 8 (unit tests) will be skipped'); + expect(result.output).toContain('[Gates 1-7] Running lint + typecheck + policy + consumer type test + surface validator + markdown gates...'); + expect(result.output).toContain('[Gate 8] Skipped (WARP_QUICK_PUSH quick mode)'); + expect([...result.commands].sort()).toEqual([ + 'lint', + 'lint:md', + 'lint:md:code', + 'typecheck', + 'typecheck:consumer', + 'typecheck:policy', + 'typecheck:surface', + ]); + expect(result.lycheeCalls).toEqual(['--config .lychee.toml **/*.md']); + }); + + it('runs Gate 8 in normal mode', () => { + const result = runPrePushHook(); + + expect(result.status).toBe(0); + expect(result.output).toContain('[Gate 8] Running unit tests...'); + expect([...result.commands].sort()).toEqual([ + 'lint', + 'lint:md', + 'lint:md:code', + 'test:local', + 'typecheck', + 'typecheck:consumer', + 'typecheck:policy', + 'typecheck:surface', + ]); + }); + + const failureCases = [ + ['typecheck', 'BLOCKED — Gate 1 FAILED: TypeScript compiler (strict mode)'], + ['typecheck:policy', 'BLOCKED — Gate 2 FAILED: IRONCLAD policy (any/wildcard/ts-ignore ban)'], + ['typecheck:consumer', 'BLOCKED — Gate 3 FAILED: Consumer type surface test'], + ['lint', 'BLOCKED — Gate 4 FAILED: ESLint (includes no-explicit-any, no-unsafe-*)'], + ['typecheck:surface', 'BLOCKED — Gate 5 FAILED: Declaration surface validator'], + ['lint:md', 'BLOCKED — Gate 6 FAILED: Markdown lint'], + ['lint:md:code', 'BLOCKED — Gate 7 FAILED: Markdown JS/TS code-sample syntax check'], + ['test:local', 'BLOCKED — Gate 8 FAILED: Unit tests'], + ]; + + for (const [failCommand, expectedMessage] of failureCases) { + it(`reports ${expectedMessage}`, () => { + const result = runPrePushHook({ + quick: failCommand !== 'test:local', + failCommand, + }); + + expect(result.status).toBe(1); + expect(result.output).toContain(expectedMessage); + }); + } +});