From 47e383d516d5b94b066c242c378af624313c3485 Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:35:07 -0400 Subject: [PATCH 1/8] fix(hooks): delegate the generated pre-commit hook to the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated `.githooks/pre-commit` was a 53-byte whitespace guard while `preCommit()` — a language-aware format/lint gate for JS/TS, Rust, and Python — already existed in `runtime-core.mjs` behind an argv dispatch that nothing invoked. Every consumer committed ungated and found those errors on CI instead. Three gaps: - The template never called the gate. It now launches the CLI, which dispatches to `preCommit()`, with the whitespace check kept as an offline fallback so an unavailable npx cannot block a commit. - `preCommit()` is now exported so the CLI can reach it. runtime-core's own argv dispatch is guarded by an entry-point check, because importing it would otherwise run the gate a second time on import. - `preCommit()` did not run `ci('type_check')`, so type errors still reached CI. Added to the JS/TS branch, where the dependency graph is already warm. `doctor` could not detect this: it asserted only that `core.hooksPath` was set, so a correctly enabled but inert hook reported "Repository doctor passed". It now checks the hook delegates, and warns when it is missing. Also moves three agent directories from the consumer gitignores into the shared template — `.zcode/`, `.kiro/`, and `.goose/` — so they are ignored once rather than re-added per repository. Verified: 557 tests pass; lint, format, and type-check clean. Against a consumer with a staged unused import the gate blocks the commit and prints the oxlint finding. Doctor warns on the old template and the missing file, and stays silent on the new one. --- .githooks/pre-commit | 19 ++++++++++++++ src/cli.mjs | 10 +++++++ src/commands/doctor.mjs | 14 ++++++++++ src/runtime-core.mjs | 58 ++++++++++++++++++++++++++++++++--------- src/templates/gitignore | 3 +++ 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3d2d15e9..58735ae1 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,4 +1,23 @@ #!/usr/bin/env sh +# Generated by Code Foundry — see src/commands/sync.mjs. +# +# This used to run `git diff --cached --check` and nothing else, even though +# the language-aware gate already existed in the runtime (preCommit()). Every +# consumer therefore committed ungated and found formatting, lint, and type +# errors on CI instead. Keep this a thin launcher: the checks belong to the +# runtime so they stay consistent with the CI tasks. +# +# `--no-verify` remains the escape hatch. The fallback keeps the whitespace +# guard working offline rather than failing a commit because npx is +# unavailable. 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@latest 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/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/ From f7b897819c2e5d0186ae9ff8a6c672f05481be15 Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:45:47 -0400 Subject: [PATCH 2/8] chore: re-trigger validation for the hook fix From 13d8aae12c8bce1e48ec72b9cd0c0cf3fbe9b62c Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:57:57 -0400 Subject: [PATCH 3/8] chore(hooks): keep the generated hook comment to the attribution line The file is copied into every consumer repository, so a multi-paragraph explanation of why the launcher exists becomes clutter in each one. Keep the provenance line and the logic. --- .githooks/pre-commit | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 58735ae1..d0312861 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,15 +1,5 @@ #!/usr/bin/env sh # Generated by Code Foundry — see src/commands/sync.mjs. -# -# This used to run `git diff --cached --check` and nothing else, even though -# the language-aware gate already existed in the runtime (preCommit()). Every -# consumer therefore committed ungated and found formatting, lint, and type -# errors on CI instead. Keep this a thin launcher: the checks belong to the -# runtime so they stay consistent with the CI tasks. -# -# `--no-verify` remains the escape hatch. The fallback keeps the whitespace -# guard working offline rather than failing a commit because npx is -# unavailable. set -eu if [ -z "$(git diff --cached --name-only)" ]; then From cdd938f937e38df6767c55652d8dc7c451136b7c Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:19:59 -0400 Subject: [PATCH 4/8] chore: re-trigger validation From 660367bdf2216e0f11ee33f0104a923258d609d9 Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:30:32 -0400 Subject: [PATCH 5/8] chore: re-trigger validation From 9a21457b3de1083f9ab742f5ee796c431c07872b Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:39:20 -0400 Subject: [PATCH 6/8] chore: re-trigger validation after #682 From 95cffc0cc956684825a05adc756a9279dfdc0576 Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:04:26 -0400 Subject: [PATCH 7/8] fix(hooks): pin the generated gate to the version that generated it The generated hook ran `npx --yes code-foundry@latest`, a mutable dist-tag, on every commit with --yes suppressing the install prompt. Any publish under that tag would execute on every developer machine at commit time with the working tree and local credentials in reach. sync now substitutes the package version into the hook, so the gate a repository receives is the gate that generated it. Added three tests: the version is pinned, no mutable dist-tag survives generation, and the whitespace fallback stays a fallback rather than a short-circuit. --- .githooks/pre-commit | 2 +- src/commands/sync.mjs | 17 +++++++++ test/sync-pre-commit-hook.test.mjs | 58 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 test/sync-pre-commit-hook.test.mjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d0312861..dc615cf5 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -6,7 +6,7 @@ if [ -z "$(git diff --cached --name-only)" ]; then exit 0 fi -if command -v npx >/dev/null 2>&1 && npx --yes code-foundry@latest pre-commit; then +if command -v npx >/dev/null 2>&1 && npx --yes code-foundry@__VERSION__ pre-commit; then exit 0 fi 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/test/sync-pre-commit-hook.test.mjs b/test/sync-pre-commit-hook.test.mjs new file mode 100644 index 00000000..a5fc780a --- /dev/null +++ b/test/sync-pre-commit-hook.test.mjs @@ -0,0 +1,58 @@ +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.match(hook, new RegExp(`code-foundry@${version.replace(/\./g, '\\.')} pre-commit`)) + } 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 }) + } + }) +}) From 50fbf38b3bb7a76c9dc79c235a60ae238c9e54b1 Mon Sep 17 00:00:00 2001 From: "A. Mahoney-Fernandes" <83057795+0xPlayerOne@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:13:36 -0400 Subject: [PATCH 8/8] test: assert the pinned gate with a substring check Building a RegExp from the package version drew js/incomplete-sanitization. The substring check states the same thing and needs no escaping. --- test/sync-pre-commit-hook.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/sync-pre-commit-hook.test.mjs b/test/sync-pre-commit-hook.test.mjs index a5fc780a..ca5b90b3 100644 --- a/test/sync-pre-commit-hook.test.mjs +++ b/test/sync-pre-commit-hook.test.mjs @@ -23,7 +23,10 @@ describe('Generated pre-commit hook', () => { syncRepository({ target: root, source: process.cwd() }) const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8') const version = readPackageVersion(process.cwd()) - assert.match(hook, new RegExp(`code-foundry@${version.replace(/\./g, '\\.')} pre-commit`)) + 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 }) }