Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ------ |
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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`).
Expand Down
2 changes: 1 addition & 1 deletion scripts/hooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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 "══════════════════════════════════════════════════════════"
Expand Down
192 changes: 192 additions & 0 deletions test/unit/scripts/pre-push-hook.test.js
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>} */
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);
});
}
});
Loading