diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3d2d15e9..dc615cf5 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,4 +1,13 @@ #!/usr/bin/env sh +# Generated by Code Foundry — see src/commands/sync.mjs. set -eu +if [ -z "$(git diff --cached --name-only)" ]; then + exit 0 +fi + +if command -v npx >/dev/null 2>&1 && npx --yes code-foundry@__VERSION__ pre-commit; then + exit 0 +fi + git diff --cached --check diff --git a/src/cli.mjs b/src/cli.mjs index 2c3f513b..e80e6870 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -271,6 +271,16 @@ async function main() { } catch (error) { fail(error instanceof Error ? error.message : String(error)) } + } else if (command === 'pre-commit') { + // The generated `.githooks/pre-commit` calls this. Without a CLI entry + // point the language-aware gate in runtime-core is unreachable from a hook. + try { + process.chdir(target) + const { preCommit } = await import('./runtime-core.mjs') + preCommit() + } catch (error) { + fail(error instanceof Error ? error.message : String(error)) + } } else fail(`unknown command: ${command}`) } diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs index 2a460ac7..ff135adb 100644 --- a/src/commands/doctor.mjs +++ b/src/commands/doctor.mjs @@ -40,6 +40,20 @@ export function doctor(root, options = {}) { } const hooks = git(target, ['config', '--get', 'core.hooksPath']) if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`') + // A correctly enabled hook that enforces nothing is the failure this check + // could not see: the generated template used to be the whitespace guard + // alone while the real gate sat unused in the runtime. Reading only the + // hooksPath reports success for a hook that does nothing. + if (hooks === '.githooks') { + const hookFile = join(target, '.githooks/pre-commit') + if (!existsSync(hookFile)) warn('No .githooks/pre-commit found; run `npx code-foundry sync`') + else if ( + !/code-foundry[@\w./-]*\s+(?:--yes\s+)?pre-commit/.test(readFileSync(hookFile, 'utf8')) + ) + warn( + '.githooks/pre-commit does not delegate to the Code Foundry gate; run `npx code-foundry sync`' + ) + } if ( ['rust', 'python', 'solidity'].some((language) => profile.languages.split(',').includes(language) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 3464d109..d4da2711 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -260,6 +260,9 @@ function synchronize(options) { if (file === 'LICENSE' && license !== 'preserve' && license !== 'none') continue if (file === '.github/CODEOWNERS' && existsSync(destination)) continue let content = readFileSync(sourceFile) + if (file === '.githooks/pre-commit') { + content = Buffer.from(renderHook(sourceFile, source)) + } if (file === 'release-please-config.json') { content = Buffer.from(renderReleaseConfig(target, sourceFile)) } @@ -586,6 +589,20 @@ function renderReleaseConfig(target, sourceFile) { return `${JSON.stringify(mergeReleaseConfig(target, sourceFile), null, 2)}\n` } +/** + * The hook runs `npx code-foundry@` on every commit, so the version + * is substituted at generation time rather than resolved at run time. A + * mutable dist-tag there would let any publish under that tag run code on + * every developer's machine at commit time; pinning means the gate a + * repository received is the gate that generated it. + * + * @param {string} sourceFile the template path + * @param {string} sourceRoot the package root that carries package.json + */ +function renderHook(sourceFile, sourceRoot) { + return readFileSync(sourceFile, 'utf8').replaceAll('__VERSION__', readPackageVersion(sourceRoot)) +} + /** @param {string} file @param {string} languages @param {string} features @param {Record} config */ function shouldInclude(file, languages, features, config) { if (file === 'ruff.toml') return includesValue(languages, 'python') diff --git a/src/runtime-core.mjs b/src/runtime-core.mjs index 60b131ac..4691a4ff 100644 --- a/src/runtime-core.mjs +++ b/src/runtime-core.mjs @@ -7,9 +7,11 @@ import { mkdirSync, readdirSync, readFileSync, + realpathSync, writeFileSync, } from 'node:fs' import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' import { detectPackageManager, resolveProfile } from './lib/profile.mjs' import { configured, readConfig } from './lib/config.mjs' @@ -1165,7 +1167,15 @@ function printProfile(command) { writeOutput('npm_publish', config.npm_publish ?? 'false') } -function preCommit() { +/** + * The local commit gate. + * + * Exported so the generated `.githooks/pre-commit` can reach it: the hook is + * what turns this into an actual gate, and it used to run the whitespace + * check alone. The generated hook is the consumer-visible contract, so keep + * the two in step. + */ +export function preCommit() { const changed = capture('git', ['diff', '--cached', '--name-only']) if (!changed) return const check = spawnSync('git', ['diff', '--cached', '--check'], { cwd: root, stdio: 'inherit' }) @@ -1173,6 +1183,10 @@ function preCommit() { if (/\.(js|jsx|ts|tsx|json|md|mdx|yml|yaml)$/.test(changed)) { ci('format') ci('lint') + // Type errors are the fastest check that still needs a full toolchain, and + // the most common reason a green-looking commit fails CI later. Run it + // here while the dependency graph is warm. + ci('type_check') } if (/\.rs$|(^|\/)Cargo\.toml$/.test(changed)) { run('cargo', ['fmt', '--check']) @@ -1184,16 +1198,34 @@ function preCommit() { } } -const [area, task, ecosystem] = process.argv.slice(2) -try { - if (area === 'ci') ci(task) - else if (area === 'security') security(task, ecosystem) - else if (area === 'codeql') codeql() - else if (area === 'profile') printProfile(task) - else if (area === 'validation') validation(task) - else if (area === 'pre-commit') preCommit() - else throw new Error(`Unknown runtime command: ${area || '(missing)'}`) -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - process.exitCode = 1 +// Only dispatch when this module is the process entry point. The CLI imports +// `preCommit` from here, and an unguarded dispatch would read argv and run the +// gate a second time on import. +const invokedDirectly = + process.argv[1] !== undefined && + realpathSafe(process.argv[1]) === realpathSafe(fileURLToPath(import.meta.url)) + +/** @param {string} p @returns {string} */ +function realpathSafe(p) { + try { + return realpathSync(p) + } catch { + return p + } +} + +if (invokedDirectly) { + const [area, task, ecosystem] = process.argv.slice(2) + try { + if (area === 'ci') ci(task) + else if (area === 'security') security(task, ecosystem) + else if (area === 'codeql') codeql() + else if (area === 'profile') printProfile(task) + else if (area === 'validation') validation(task) + else if (area === 'pre-commit') preCommit() + else throw new Error(`Unknown runtime command: ${area || '(missing)'}`) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } } diff --git a/src/templates/gitignore b/src/templates/gitignore index f6ca16fd..6f19cea1 100644 --- a/src/templates/gitignore +++ b/src/templates/gitignore @@ -72,6 +72,9 @@ pnpm-debug.log* .fleet/ .opencode/ .kluster/ +.zcode/ +.kiro/ +.goose/ .direnv/ .envrc .vercel/ diff --git a/test/sync-pre-commit-hook.test.mjs b/test/sync-pre-commit-hook.test.mjs new file mode 100644 index 00000000..ca5b90b3 --- /dev/null +++ b/test/sync-pre-commit-hook.test.mjs @@ -0,0 +1,61 @@ +import { strict as assert } from 'node:assert' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' +import { syncRepository, readPackageVersion } from '../src/commands/sync.mjs' + +function consumer() { + const root = mkdtempSync(join(tmpdir(), 'code-foundry-hook-pin-')) + mkdirSync(join(root, '.github/workflows'), { recursive: true }) + writeFileSync(join(root, 'package.json'), '{"name":"fixture","version":"1.0.0"}\n') + writeFileSync( + join(root, '.github/code-foundry.yml'), + 'languages: typescript\npackage_manager: bun\nfeatures: all\ngit_workflow: direct\nmerge_strategy: squash\nrelease_merge_strategy: squash\n' + ) + return root +} + +describe('Generated pre-commit hook', () => { + it('pins the gate to the version that generated it', () => { + const root = consumer() + try { + syncRepository({ target: root, source: process.cwd() }) + const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8') + const version = readPackageVersion(process.cwd()) + assert.ok( + hook.includes(`code-foundry@${version} pre-commit`), + `expected the gate pinned to ${version}, got: ${hook.split('\n').find((l) => l.includes('npx')) ?? ''}` + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('never resolves the gate through a mutable dist-tag', () => { + const root = consumer() + try { + syncRepository({ target: root, source: process.cwd() }) + const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8') + // A dist-tag here would let any publish under that tag execute code on + // every developer's machine at commit time. + assert.doesNotMatch(hook, /code-foundry@(latest|next|beta|canary)\b/) + assert.doesNotMatch(hook, /__VERSION__/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the whitespace guard as an offline fallback', () => { + const root = consumer() + try { + syncRepository({ target: root, source: process.cwd() }) + const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8') + assert.match(hook, /git diff --cached --check/) + // The fallback must not be able to short-circuit the gate. + assert.match(hook, /exit 0[\s\S]*npx --yes code-foundry@\d+\.\d+\.\d+ pre-commit/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +})