/** Isolated hook acceptance checks and full-process timing; never stages this checkout. */ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { cpSync, mkdtempSync, readFileSync, writeFileSync, symlinkSync } from 'node:fs'; import { tmpdir, cpus, platform, release } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { performance } from 'node:perf_hooks'; const source = fileURLToPath(new URL('../../', import.meta.url)); const root = mkdtempSync(path.join(tmpdir(), 's2-hook-acceptance-')); const frontend = path.join(root, 'agiledancers-fe'); const excluded = new Set([ 'node_modules', 'dist', '.angular', 'coverage', 'test-results', 'unit-results', 'playwright-report', '_', ]); cpSync(path.join(source, 'agiledancers-fe'), frontend, { recursive: true, filter: (file) => !excluded.has(path.basename(file)), }); cpSync(path.join(source, '.husky'), path.join(root, '.husky'), { recursive: true, filter: (file) => !excluded.has(path.basename(file)), }); symlinkSync( path.join(source, 'agiledancers-fe/node_modules'), path.join(frontend, 'node_modules'), 'dir', ); const env = { ...process.env }; delete env.CI; delete env.HUSKY; function run(command, args, cwd = root, environment = env) { return execFileSync(command, args, { cwd, env: environment, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trimEnd(); } const git = (...args) => run('git', args); const write = (file, content) => writeFileSync(path.join(root, file), content); const read = (file) => readFileSync(path.join(root, file), 'utf8'); const fixture = 'agiledancers-fe/src/hook-fixture.ts'; const baseline = 'export const hookValue = { value: 1 };\n\n// Independent edits below.\n\nexport const otherValue = 1;\n'; write(fixture, baseline); write('unrelated.txt', 'baseline\n'); git('init', '-q'); git('config', 'user.name', 'S2 validation'); git('config', 'user.email', 's2@example.invalid'); git('add', '.'); git('-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture baseline'); git('config', 'core.hooksPath', '/dev/null'); run('node', ['tools/install-hooks.mjs'], frontend, { ...env, CI: 'true' }); assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null'); run('node', ['tools/install-hooks.mjs'], frontend, { ...env, HUSKY: '0' }); assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null'); console.log('PASS CI and HUSKY=0 skip hook installation'); run('node', ['tools/install-hooks.mjs'], frontend); assert.equal(git('config', '--get', 'core.hooksPath'), '.husky/_'); const reset = () => git('reset', '--hard', '-q', 'HEAD'); const snapshot = () => JSON.stringify([ git('diff', '--cached', '--binary'), git('diff', '--binary'), git('status', '--porcelain'), git('stash', 'list'), ]); function hook() { const start = performance.now(); const result = spawnSync('sh', ['.husky/_/pre-commit'], { cwd: root, env, encoding: 'utf8' }); return { seconds: Number(((performance.now() - start) / 1000).toFixed(3)), exit: result.status, output: result.stdout + result.stderr, }; } function passed(name) { console.log(`PASS ${name}`); } // Format only the index version while independently staged/unstaged edits survive. write(fixture, baseline.replace('{ value: 1 }', '{value:2}')); git('add', fixture); write(fixture, read(fixture).replace('otherValue = 1', 'otherValue = 2')); write('unrelated.txt', 'staged\n'); git('add', 'unrelated.txt'); write('unrelated.txt', 'staged\nunstaged\n'); const result = hook(); assert.equal(result.exit, 0, result.output); assert.match(git('show', `:${fixture}`), /hookValue = \{ value: 2 \}/); assert.match(git('show', `:${fixture}`), /otherValue = 1/); assert.match(read(fixture), /otherValue = 2/); assert.equal(git('show', ':unrelated.txt'), 'staged'); assert.equal(read('unrelated.txt'), 'staged\nunstaged\n'); assert.equal(git('stash', 'list'), ''); passed('partially staged formatting preserves unrelated staged and unstaged changes'); reset(); write('unrelated.txt', 'only backend/documentation\n'); git('add', 'unrelated.txt'); let before = snapshot(); assert.equal(hook().exit, 0); assert.equal(snapshot(), before); passed('commits without frontend changes leave state unchanged'); reset(); write('agiledancers-fe/package-lock.json', read('agiledancers-fe/package-lock.json') + '\n\n'); git('add', 'agiledancers-fe/package-lock.json'); before = snapshot(); assert.equal(hook().exit, 0); assert.equal(snapshot(), before); passed('generated lockfile is excluded'); reset(); write( 'agiledancers-fe/src/app/app.ts', read('agiledancers-fe/src/app/app.ts') + '\nexport const formatterCheck={value:2};\n', ); write(fixture, 'export const broken = ;\n'); git('add', 'agiledancers-fe/src/app/app.ts', fixture); write('unrelated.txt', 'unstaged survives failures\n'); before = snapshot(); const failed = hook(); assert.notEqual(failed.exit, 0); assert.match(failed.output, /SyntaxError|Unexpected token/); assert.equal(snapshot(), before); passed('formatter failure restores index, worktree and stash'); reset(); write(fixture, baseline.replace('{ value: 1 }', '{value:2}')); git('add', fixture); write(fixture, read(fixture).replace('{value:2}', '{value:3}')); before = snapshot(); const conflict = hook(); assert.notEqual(conflict.exit, 0, conflict.output); assert.match(conflict.output, /conflict|Failed to restore unstaged/i); assert.equal(snapshot(), before); passed('unstaged reconciliation conflict restores original state'); reset(); // Benchmark the rejected combined candidate using the same hook and state protections. const selectedConfig = read('agiledancers-fe/lint-staged.config.mjs'); write( 'agiledancers-fe/lint-staged.combined.mjs', `import base from './lint-staged.config.mjs'; export default Object.fromEntries(Object.entries(base).map(([pattern, task]) => [pattern, files => { const commands = task(files); const lintable = files.filter(file => /\\.(ts|js|mjs|cjs|html)$/.test(file)); return commands.length && lintable.length ? [...commands, 'eslint --max-warnings 0 ' + lintable.map(file => JSON.stringify(file)).join(' ')] : commands; }]));\n`, ); const selectedHook = read('.husky/pre-commit'); write( '.husky/pre-commit', selectedHook.replace( '--concurrent false', '--concurrent false --config lint-staged.combined.mjs', ), ); write(fixture, baseline.replace('{ value: 1 }', '{value:2}')); write( 'agiledancers-fe/src/app/app.ts', read('agiledancers-fe/src/app/app.ts') + '\nexport const badLint: any = 1;\n', ); git('add', fixture, 'agiledancers-fe/src/app/app.ts'); before = snapshot(); const lintFailure = hook(); assert.notEqual(lintFailure.exit, 0); assert.match(lintFailure.output, /no-explicit-any/); assert.equal(snapshot(), before); passed('combined candidate restores formatting after ESLint failure'); reset(); const all = git('ls-files', 'agiledancers-fe') .split('\n') .filter((file) => !file.endsWith('/package-lock.json')); const scopes = { single: ['agiledancers-fe/src/app/app.ts'], mixed: [ 'agiledancers-fe/src/app/app.ts', 'agiledancers-fe/src/app/app.html', 'agiledancers-fe/src/app/app.css', 'agiledancers-fe/angular.json', 'agiledancers-fe/eslint.config.mjs', 'agiledancers-fe/e2e/school-management.spec.ts', ], all, }; const timings = []; for (const mode of ['combined', 'prettier-only']) { for (const [scope, files] of Object.entries(scopes)) { for (let repeat = 1; repeat <= 3; repeat++) { reset(); write( '.husky/pre-commit', mode === 'combined' ? selectedHook.replace( '--concurrent false', '--concurrent false --config lint-staged.combined.mjs', ) : selectedHook, ); for (const file of files) { const addition = /\.(ts|js|mjs|cjs)$/.test(file) ? '\n// Hook timing fixture.\n' : file.endsWith('.html') ? '\n\n' : file.endsWith('.css') ? '\n/* Hook timing fixture. */\n' : '\n'; write(file, read(file) + addition); } git('add', '--', ...files); const measurement = hook(); assert.equal(measurement.exit, 0, measurement.output); timings.push({ mode, scope, repeat, files, seconds: measurement.seconds }); console.log(`TIMING ${mode} ${scope} ${repeat}: ${measurement.seconds}s`); } } } assert.equal(read('agiledancers-fe/lint-staged.config.mjs').includes(selectedConfig.trim()), true); const report = { machine: cpus()[0].model, system: `${platform()} ${release()}`, node: process.version, npm: run('npm', ['--version']), root, timings, }; writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n'); console.log(`Evidence: ${path.join(root, 'report.json')}`); const selected = timings.filter((result) => result.mode === 'prettier-only'); assert.ok( selected.every((result) => result.seconds <= 3), 'Prettier-only exceeds the three-second budget; do not enable the hook.', );