/** * Regression tests for bug #2136 / #2206 * * Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh, * gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the * stale-hook detector in gsd-check-update.js only matched JavaScript comment * syntax (//) — not bash comment syntax (#). * * Result: every session showed "⚠ stale hooks — run /gsd-update" immediately * after a fresh install, because the detector saw hookVersion: 'unknown' for * all three bash hooks. * * This fix requires THREE parts working in concert: * 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}" * 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time * 3. gsd-check-update.js regex matches both "//" and "#" comment styles * * Neither fix alone is sufficient: * - Headers + regex fix only (no install.js fix): installed hooks contain * literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making * bash hook staleness permanently undetectable after future updates. * - Headers + install.js fix only (no regex fix): installed hooks are * stamped correctly but the detector still can't read bash "#" comments, * so they still land in the "unknown / stale" branch on every session. */ 'use strict'; // NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the // real installer subprocess, which skips all install logic when GSD_TEST_MODE=1. const { describe, test, before, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { execFileSync } = require('child_process'); const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); const CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js'); const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const SH_HOOKS = [ 'gsd-phase-boundary.sh', 'gsd-session-state.sh', 'gsd-validate-commit.sh', ]; // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe', }); }); // ─── Helpers ───────────────────────────────────────────────────────────────── function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } function runInstaller(configDir) { // --no-sdk: this test covers .sh hook version stamping only; skip SDK // build (covered by install-smoke.yml). execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { encoding: 'utf-8', stdio: 'pipe', env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, }); return path.join(configDir, 'hooks'); } // ───────────────────────────────────────────────────────────────────────────── // Part 1: Bash hook sources carry the version header placeholder // ───────────────────────────────────────────────────────────────────────────── describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => { for (const sh of SH_HOOKS) { test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => { const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8'); assert.ok( content.includes('# gsd-hook-version: {{GSD_VERSION}}'), `${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` + `installer can stamp it and gsd-check-update.js can detect staleness` ); }); } test('version header is on line 2 (immediately after shebang)', () => { // Placing the header immediately after #!/bin/bash ensures it is always // found regardless of how much of the file is read. for (const sh of SH_HOOKS) { const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split('\n'); assert.strictEqual(lines[0], '#!/bin/bash', `${sh} line 1 must be #!/bin/bash`); assert.ok( lines[1].startsWith('# gsd-hook-version:'), `${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")` ); } }); }); // ───────────────────────────────────────────────────────────────────────────── // Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax // (Logic moved from inline -e template literal to dedicated worker file) // ───────────────────────────────────────────────────────────────────────────── describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => { let src; before(() => { src = fs.readFileSync(WORKER_FILE, 'utf8'); }); test('version regex in source matches "#" comment syntax in addition to "//"', () => { // The regex string in the source must contain the alternation for "#". // The worker uses plain JS (no template-literal escaping), so the form is // "(?:\/\/|#)" directly in source. const hasBashAlternative = src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style) src.includes('(?:\/\/|#)'); // direct form in plain JS worker assert.ok( hasBashAlternative, 'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' + 'Expected to find (?:\\/\\/|#) or (?:\/\/|#) in the source. ' + 'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"' ); }); test('version regex does not use the old JS-only form as the sole pattern', () => { // The old regex inside the template literal was the string: // /\\/\\/ gsd-hook-version:\\s*(.+)/ // which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/ // That only matched JS "//" comments — never bash "#". // We verify that the old exact string no longer appears. assert.ok( !src.includes('\\/\\/ gsd-hook-version'), 'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' + 'escape form as the sole version matcher — it cannot match bash "#" comments' ); }); test('version regex correctly matches both bash and JS hook version headers', () => { // Verify that the versionMatch line in the source uses a regex that matches // both bash "#" and JS "//" comment styles. We check the source contains the // expected alternation, then directly test the known required pattern. // // We do NOT try to extract and evaluate the regex from source (it contains ")" // which breaks simple extraction), so instead we confirm the source matches // our expectation and run the regex itself. assert.ok( src.includes('gsd-hook-version'), 'gsd-check-update-worker.js must contain a gsd-hook-version version check' ); // The fixed regex that must be present: matches both comment styles const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/; assert.ok( fixedRegex.test('# gsd-hook-version: 1.36.0'), 'bash-style "# gsd-hook-version: X" must be matchable by the required regex' ); assert.ok( fixedRegex.test('// gsd-hook-version: 1.36.0'), 'JS-style "// gsd-hook-version: X" must still match (no regression)' ); assert.ok( !fixedRegex.test('gsd-hook-version: 1.36.0'), 'line without a comment prefix must not match (prevents false positives)' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // Part 3a: install.js bundled path substitutes {{GSD_VERSION}} in .sh hooks // ───────────────────────────────────────────────────────────────────────────── describe('bug #2136 part 3a: install.js bundled path substitutes {{GSD_VERSION}} in .sh hooks', () => { let src; before(() => { src = fs.readFileSync(INSTALL_SCRIPT, 'utf8'); }); test('.sh branch in bundled hook copy loop reads file and substitutes GSD_VERSION', () => { // Anchor on configDirReplacement — unique to the bundled-hooks path. const anchorIdx = src.indexOf('configDirReplacement'); assert.ok(anchorIdx !== -1, 'bundled hook copy loop anchor (configDirReplacement) not found'); // Window large enough for the if/else block const region = src.slice(anchorIdx, anchorIdx + 2000); assert.ok( region.includes("entry.endsWith('.sh')"), "bundled hook copy loop must check entry.endsWith('.sh')" ); assert.ok( region.includes('GSD_VERSION'), 'bundled .sh branch must reference GSD_VERSION substitution. Without this, ' + 'installed .sh hooks contain the literal "{{GSD_VERSION}}" placeholder and ' + 'bash hook staleness becomes permanently undetectable after future updates' ); // copyFileSync on a .sh file would skip substitution — ensure we read+write instead const shBranchIdx = region.indexOf("entry.endsWith('.sh')"); const shBranchRegion = region.slice(shBranchIdx, shBranchIdx + 400); assert.ok( shBranchRegion.includes('readFileSync') || shBranchRegion.includes('writeFileSync'), 'bundled .sh branch must read the file (readFileSync) to perform substitution, ' + 'not copyFileSync directly (which skips template expansion)' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // Part 3b: install.js Codex path also substitutes {{GSD_VERSION}} in .sh hooks // ───────────────────────────────────────────────────────────────────────────── describe('bug #2136 part 3b: install.js Codex path substitutes {{GSD_VERSION}} in .sh hooks', () => { let src; before(() => { src = fs.readFileSync(INSTALL_SCRIPT, 'utf8'); }); test('.sh branch in Codex hook copy block substitutes GSD_VERSION', () => { // Anchor on codexHooksSrc — unique to the Codex path. const anchorIdx = src.indexOf('codexHooksSrc'); assert.ok(anchorIdx !== -1, 'Codex hook copy block anchor (codexHooksSrc) not found'); const region = src.slice(anchorIdx, anchorIdx + 2000); assert.ok( region.includes("entry.endsWith('.sh')"), "Codex hook copy block must check entry.endsWith('.sh')" ); assert.ok( region.includes('GSD_VERSION'), 'Codex .sh branch must substitute {{GSD_VERSION}}. The bundled path was fixed ' + 'but Codex installs a separate copy of the hooks from hooks/dist that also needs stamping' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder // ───────────────────────────────────────────────────────────────────────────── describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => { let tmpDir; beforeEach(() => { tmpDir = createTempDir('gsd-2136-install-'); }); afterEach(() => { cleanup(tmpDir); }); test('installed .sh hooks contain a concrete version string, not the template placeholder', () => { const hooksDir = runInstaller(tmpDir); for (const sh of SH_HOOKS) { const hookPath = path.join(hooksDir, sh); assert.ok(fs.existsSync(hookPath), `${sh} must be installed`); const content = fs.readFileSync(hookPath, 'utf8'); assert.ok( content.includes('# gsd-hook-version:'), `installed ${sh} must contain a "# gsd-hook-version:" header` ); assert.ok( !content.includes('{{GSD_VERSION}}'), `installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` + `install.js must substitute it with the concrete package version` ); const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/); assert.ok(versionMatch, `installed ${sh} version header must have a version value`); assert.match( versionMatch[1], /^\d+\.\d+\.\d+/, `installed ${sh} version "${versionMatch[1]}" must be a semver-like string` ); } }); test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => { // This is the definitive end-to-end proof: after install, run the actual // version-check logic (extracted from gsd-check-update.js) against the // installed hooks and verify none are flagged stale. const hooksDir = runInstaller(tmpDir); const pkg = require(path.join(__dirname, '..', 'package.json')); const installedVersion = pkg.version; // Build a subprocess that runs the staleness check logic in isolation. // We pass the installed version, hooks dir, and hook filenames as JSON // to avoid any injection risk. const checkScript = ` 'use strict'; const fs = require('fs'); const path = require('path'); function isNewer(a, b) { const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0); const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0); for (let i = 0; i < 3; i++) { if (pa[i] > pb[i]) return true; if (pa[i] < pb[i]) return false; } return false; } const hooksDir = ${JSON.stringify(hooksDir)}; const installed = ${JSON.stringify(installedVersion)}; const shHooks = ${JSON.stringify(SH_HOOKS)}; // Use the same regex that the fixed gsd-check-update.js uses const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/; const staleHooks = []; for (const hookFile of shHooks) { const hookPath = path.join(hooksDir, hookFile); if (!fs.existsSync(hookPath)) { staleHooks.push({ file: hookFile, hookVersion: 'missing' }); continue; } const content = fs.readFileSync(hookPath, 'utf8'); const m = content.match(versionRe); if (m) { const hookVersion = m[1].trim(); if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) { staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed }); } } else { staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed }); } } process.stdout.write(JSON.stringify(staleHooks)); `; const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' }); const staleHooks = JSON.parse(result); assert.deepStrictEqual( staleHooks, [], `Fresh install must produce zero stale bash hooks.\n` + `Got: ${JSON.stringify(staleHooks, null, 2)}\n` + `This indicates either the version header was not stamped by install.js, ` + `or the detector regex cannot match bash "#" comment syntax.` ); }); });