code-foundry 1.46.4 → 1.46.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +40 -0
- package/.gitignore +5 -0
- package/CHANGELOG.md +15 -15
- package/docs/CONFIGURATION.md +27 -2
- package/package.json +1 -1
- package/src/commands/doctor.mjs +25 -2
- package/src/commands/sync.mjs +144 -1
- package/src/lib/pre-commit.mjs +19 -0
- package/src/runtime-core.mjs +43 -21
- package/src/templates/gitignore +5 -0
package/.githooks/pre-commit
CHANGED
|
@@ -6,6 +6,46 @@ if [ -z "$(git diff --cached --name-only)" ]; then
|
|
|
6
6
|
exit 0
|
|
7
7
|
fi
|
|
8
8
|
|
|
9
|
+
# core.hooksPath names a single directory, so pointing it at .githooks would
|
|
10
|
+
# silence every machine-level hook behind it: secret guards, git-lfs, signing.
|
|
11
|
+
# Chain to the machine-level pre-commit when one exists. The target is
|
|
12
|
+
# discovered at run time — never baked into this committed file — from the
|
|
13
|
+
# repository's previous hooks directory (recorded by sync), then the global
|
|
14
|
+
# hooksPath, then the checkout's default hooks directory. The machine hook
|
|
15
|
+
# runs first so cheap secret guards reject before the heavier project gate.
|
|
16
|
+
machine_hook() {
|
|
17
|
+
hook_name="$1"
|
|
18
|
+
for dir in \
|
|
19
|
+
"$(git config --get code-foundry.previousHooksPath 2>/dev/null || true)" \
|
|
20
|
+
"$(git config --global --get core.hooksPath 2>/dev/null || true)" \
|
|
21
|
+
"$(git rev-parse --git-common-dir 2>/dev/null || printf '.git')/hooks"
|
|
22
|
+
do
|
|
23
|
+
case "$dir" in
|
|
24
|
+
'' | .githooks | ./.githooks | */.githooks) continue ;;
|
|
25
|
+
esac
|
|
26
|
+
candidate="$dir/$hook_name"
|
|
27
|
+
if [ -f "$candidate" ] && ! [ "$candidate" -ef "$0" ]; then
|
|
28
|
+
printf '%s' "$candidate"
|
|
29
|
+
return 0
|
|
30
|
+
fi
|
|
31
|
+
done
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if [ -z "${CODE_FOUNDRY_HOOK_CHAIN:-}" ]; then
|
|
36
|
+
if machine="$(machine_hook pre-commit)"; then
|
|
37
|
+
# The marker travels through the machine hook into anything it delegates
|
|
38
|
+
# back to: a guard that chains to .githooks/pre-commit (a mutual-delegation
|
|
39
|
+
# pair) re-enters this hook, sees the marker, and runs the gate instead of
|
|
40
|
+
# chaining again — machine policy once, project policy once, no loop.
|
|
41
|
+
if [ -x "$machine" ]; then
|
|
42
|
+
CODE_FOUNDRY_HOOK_CHAIN=1 "$machine" "$@" || exit $?
|
|
43
|
+
else
|
|
44
|
+
CODE_FOUNDRY_HOOK_CHAIN=1 sh "$machine" "$@" || exit $?
|
|
45
|
+
fi
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
|
|
9
49
|
# Prefer a runtime the repository already depends on. A repository opts into
|
|
10
50
|
# the gate by depending on code-foundry; one that does not keeps the
|
|
11
51
|
# whitespace guard and stays offline. Nothing is fetched at commit time.
|
package/.gitignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.46.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.5...v1.46.6) (2026-10-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **pre-commit:** build before lint and type-check when the gate opts in ([#748](https://github.com/0xPlayerOne/code-foundry/issues/748)) ([f56454f](https://github.com/0xPlayerOne/code-foundry/commit/f56454f313a1a5b802b8a17b09d850902de1ba55)), closes [#691](https://github.com/0xPlayerOne/code-foundry/issues/691)
|
|
9
|
+
|
|
10
|
+
## [1.46.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.4...v1.46.5) (2026-10-08)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **sync:** chain machine-level hooks instead of silencing them ([#747](https://github.com/0xPlayerOne/code-foundry/issues/747)) ([7fcad52](https://github.com/0xPlayerOne/code-foundry/commit/7fcad52d400dbb2aaa9ba46c0c76f14e8d63f660)), closes [#690](https://github.com/0xPlayerOne/code-foundry/issues/690)
|
|
16
|
+
|
|
3
17
|
## [1.46.4](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.3...v1.46.4) (2026-10-08)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -126,23 +140,9 @@
|
|
|
126
140
|
|
|
127
141
|
* **ci:** cancel only superseded pull-request runs in managed workflows ([#710](https://github.com/0xPlayerOne/code-foundry/issues/710)) ([f5fead6](https://github.com/0xPlayerOne/code-foundry/commit/f5fead677c032d253f2e1a2a256da4f207ddfa2a))
|
|
128
142
|
|
|
129
|
-
## [1.44.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.0...v1.44.1) (2026-10-03)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
### Bug Fixes
|
|
133
|
-
|
|
134
|
-
* **release:** budget the package without the changelog and ship recent history only ([#708](https://github.com/0xPlayerOne/code-foundry/issues/708)) ([ab365e0](https://github.com/0xPlayerOne/code-foundry/commit/ab365e0910472c3596429adc86a1c8f3e19730f9))
|
|
135
|
-
|
|
136
|
-
## [1.44.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.2...v1.44.0) (2026-10-03)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
### Features
|
|
140
|
-
|
|
141
|
-
* **pre-commit:** make the commit gate change-aware ([#702](https://github.com/0xPlayerOne/code-foundry/issues/702)) ([f5bca91](https://github.com/0xPlayerOne/code-foundry/commit/f5bca91c177b30b99f5d62287255d191617b4788))
|
|
142
|
-
|
|
143
143
|
<!-- code-foundry: packaged changelog -->
|
|
144
144
|
|
|
145
145
|
---
|
|
146
146
|
|
|
147
|
-
This packaged changelog lists the 20 most recent releases;
|
|
147
|
+
This packaged changelog lists the 20 most recent releases; 241 older releases are omitted to keep the package small.
|
|
148
148
|
See the [full changelog](https://github.com/0xPlayerOne/code-foundry/blob/main/CHANGELOG.md) for the complete history.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -131,7 +131,7 @@ See [Merge queue validation](merge-queues.md) before enabling it.
|
|
|
131
131
|
| `coverage_minimum` | `0`–`100` | Minimum percentage; defaults to `80`. |
|
|
132
132
|
| `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the coverage gate. |
|
|
133
133
|
| `coverage_report` | comma-separated repository paths | Istanbul JSON summary or LCOV evidence files. |
|
|
134
|
-
| `pre_commit_build` | `true`, `false` | Run the project's build in the local pre-commit gate. Defaults to `false`; see [Pre-commit gate](#pre-commit-gate).
|
|
134
|
+
| `pre_commit_build` | `true`, `false` | Run the project's build in the local pre-commit gate, before lint and type-check (mirroring CI's ordering). Defaults to `false`; see [Pre-commit gate](#pre-commit-gate). |
|
|
135
135
|
| `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
|
|
136
136
|
| `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
|
|
137
137
|
| `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
|
|
@@ -194,13 +194,38 @@ gate is change-aware and never runs work for code the commit cannot affect:
|
|
|
194
194
|
Clippy.
|
|
195
195
|
- The build does not run at commit time; CI and explicit commands own it. Set
|
|
196
196
|
`pre_commit_build: true` to run the `build` script (or `cargo build`) when
|
|
197
|
-
code is staged.
|
|
197
|
+
code is staged. The build then runs before lint and type-check — mirroring
|
|
198
|
+
CI's ordering — because those tasks read built package output. With the
|
|
199
|
+
build disabled, a lint or type-check failure that a fresh clone's missing
|
|
200
|
+
build output could explain is printed with a one-line note saying so instead
|
|
201
|
+
of a bare error.
|
|
198
202
|
|
|
199
203
|
Tools read working-tree contents, so a partially staged file is checked as it
|
|
200
204
|
exists on disk. Task scripts that declare dependencies of their own, such as a
|
|
201
205
|
Turborepo `typecheck` task with `dependsOn: ["^build"]`, still run them; drop
|
|
202
206
|
that dependency to keep type checks build-free.
|
|
203
207
|
|
|
208
|
+
### Machine-level hook chaining
|
|
209
|
+
|
|
210
|
+
`core.hooksPath` names a single directory, so enabling `.githooks` would
|
|
211
|
+
silence every hook in the machine's own hooks directory: secret guards,
|
|
212
|
+
git-lfs, signing. Sync chains instead of replacing. The generated
|
|
213
|
+
`.githooks/pre-commit` discovers a machine-level `pre-commit` at run time —
|
|
214
|
+
from the hooks directory sync replaced (recorded as the
|
|
215
|
+
`code-foundry.previousHooksPath` repository config), then the global
|
|
216
|
+
`core.hooksPath`, then the checkout's default hooks directory — and runs it
|
|
217
|
+
before the gate, so a cheap secret guard rejects before the heavier project
|
|
218
|
+
checks. The committed hook stays machine-independent; the target is resolved
|
|
219
|
+
on the machine that runs it.
|
|
220
|
+
|
|
221
|
+
Hook names this repository does not own (`post-checkout`, `pre-push`,
|
|
222
|
+
`commit-msg`, and the other client-side hooks) get delegating stubs in
|
|
223
|
+
`.githooks` when a machine-level counterpart exists, which is what keeps
|
|
224
|
+
git-lfs working after sync. Stubs are machine-local, gitignored, rewritten on
|
|
225
|
+
every sync, and removed once the machine hook they mirrored disappears.
|
|
226
|
+
`code-foundry doctor` warns when a machine-level pre-commit exists but the
|
|
227
|
+
installed hook predates chaining.
|
|
228
|
+
|
|
204
229
|
## Release and branch policy
|
|
205
230
|
|
|
206
231
|
| Key | Values | Purpose |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-foundry",
|
|
3
|
-
"version": "1.46.
|
|
3
|
+
"version": "1.46.6",
|
|
4
4
|
"description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
|
|
5
5
|
"homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
|
|
6
6
|
"bugs": {
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
-
import { join, resolve } from 'node:path'
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
4
|
+
import { join, resolve, isAbsolute } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { includesValue, readConfig } from '../lib/config.mjs'
|
|
7
7
|
import { recommendRunners, resolveProfile } from '../lib/profile.mjs'
|
|
@@ -53,6 +53,29 @@ export function doctor(root, options = {}) {
|
|
|
53
53
|
warn(
|
|
54
54
|
'.githooks/pre-commit does not delegate to the Code Foundry gate; run `npx code-foundry sync`'
|
|
55
55
|
)
|
|
56
|
+
// core.hooksPath shadows every machine-level hook behind it (secret
|
|
57
|
+
// guards, git-lfs). The generated hook chains to the machine's pre-commit
|
|
58
|
+
// when sync recorded one, so a machine hook without the chain marker in
|
|
59
|
+
// the installed hook is exactly the silent-disable bug chaining exists to
|
|
60
|
+
// prevent.
|
|
61
|
+
const hookText = existsSync(hookFile) ? readFileSync(hookFile, 'utf8') : ''
|
|
62
|
+
const machineDirs = [
|
|
63
|
+
git(target, ['config', '--get', 'code-foundry.previousHooksPath']),
|
|
64
|
+
git(target, ['config', '--global', '--get', 'core.hooksPath']),
|
|
65
|
+
`${git(target, ['rev-parse', '--git-common-dir']) || '.git'}/hooks`,
|
|
66
|
+
].filter((dir) => dir && dir !== '.githooks' && dir !== './.githooks')
|
|
67
|
+
const machinePreCommit = machineDirs.some((dir) => {
|
|
68
|
+
const candidate = isAbsolute(dir) ? join(dir, 'pre-commit') : join(target, dir, 'pre-commit')
|
|
69
|
+
try {
|
|
70
|
+
return statSync(candidate).isFile()
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
if (machinePreCommit && !hookText.includes('machine_hook'))
|
|
76
|
+
warn(
|
|
77
|
+
'A machine-level pre-commit exists but the installed hook does not chain to it; run `npx code-foundry sync`'
|
|
78
|
+
)
|
|
56
79
|
}
|
|
57
80
|
if (
|
|
58
81
|
['rust', 'python', 'solidity'].some((language) =>
|
package/src/commands/sync.mjs
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
readdirSync,
|
|
8
8
|
readFileSync,
|
|
9
9
|
rmSync,
|
|
10
|
+
statSync,
|
|
10
11
|
writeFileSync,
|
|
11
12
|
} from 'node:fs'
|
|
12
|
-
import { dirname, join, resolve } from 'node:path'
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
13
14
|
import { spawnSync } from 'node:child_process'
|
|
14
15
|
import {
|
|
15
16
|
detectLanguages,
|
|
@@ -550,7 +551,17 @@ function synchronize(options) {
|
|
|
550
551
|
existsSync(join(target, '.githooks/pre-commit'))
|
|
551
552
|
) {
|
|
552
553
|
chmodSync(join(target, '.githooks/pre-commit'), 0o755)
|
|
554
|
+
// core.hooksPath names a single directory, so pointing it at .githooks
|
|
555
|
+
// would silence every machine-level hook behind it (secret guards,
|
|
556
|
+
// git-lfs, signing). Record what is being replaced so the generated
|
|
557
|
+
// hooks can chain back to it, then delegate the hook names this
|
|
558
|
+
// repository does not own.
|
|
559
|
+
const previousHooksPath = gitOutput(target, ['config', '--get', 'core.hooksPath'])
|
|
560
|
+
if (previousHooksPath && previousHooksPath !== '.githooks') {
|
|
561
|
+
git(target, ['config', 'code-foundry.previousHooksPath', previousHooksPath])
|
|
562
|
+
}
|
|
553
563
|
git(target, ['config', 'core.hooksPath', '.githooks'])
|
|
564
|
+
syncMachineHookDelegates(target, previousHooksPath)
|
|
554
565
|
}
|
|
555
566
|
console.log(`${changed.length} baseline file(s) differ.`)
|
|
556
567
|
if (overlays.custom_workflows === 'preserve') {
|
|
@@ -1848,6 +1859,138 @@ function git(target, args) {
|
|
|
1848
1859
|
spawnSync('git', args, { cwd: target, stdio: 'ignore' })
|
|
1849
1860
|
}
|
|
1850
1861
|
|
|
1862
|
+
/** @param {string} target @param {string[]} args @returns {string} */
|
|
1863
|
+
function gitOutput(target, args) {
|
|
1864
|
+
const result = spawnSync('git', args, { cwd: target, encoding: 'utf8' })
|
|
1865
|
+
return result.status === 0 ? (result.stdout ?? '').trim() : ''
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Hook names git runs on the client that Code Foundry does not own. When one
|
|
1870
|
+
* of these exists in a machine-level hooks directory, sync writes a
|
|
1871
|
+
* delegating stub for it so enabling `core.hooksPath=.githooks` keeps the
|
|
1872
|
+
* machine's hooks (git-lfs, secret scanners) firing.
|
|
1873
|
+
*/
|
|
1874
|
+
const MACHINE_DELEGATED_HOOKS = [
|
|
1875
|
+
'applypatch-msg',
|
|
1876
|
+
'commit-msg',
|
|
1877
|
+
'prepare-commit-msg',
|
|
1878
|
+
'post-applypatch',
|
|
1879
|
+
'post-checkout',
|
|
1880
|
+
'post-commit',
|
|
1881
|
+
'post-merge',
|
|
1882
|
+
'post-rewrite',
|
|
1883
|
+
'pre-applypatch',
|
|
1884
|
+
'pre-auto-gc',
|
|
1885
|
+
'pre-rebase',
|
|
1886
|
+
'pre-push',
|
|
1887
|
+
'reference-transaction',
|
|
1888
|
+
'sendemail-validate',
|
|
1889
|
+
'post-index-change',
|
|
1890
|
+
]
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* The hooks directories a machine-level hook may live in, in the order the
|
|
1894
|
+
* generated hooks probe them: the repository hooks path sync just replaced,
|
|
1895
|
+
* then the global one, then the checkout's default directory. Relative values
|
|
1896
|
+
* resolve against the repository root, matching git's own semantics.
|
|
1897
|
+
*
|
|
1898
|
+
* @param {string} target @param {string} previousHooksPath @returns {string[]}
|
|
1899
|
+
*/
|
|
1900
|
+
function machineHooksDirectories(target, previousHooksPath) {
|
|
1901
|
+
return [
|
|
1902
|
+
previousHooksPath,
|
|
1903
|
+
gitOutput(target, ['config', '--global', '--get', 'core.hooksPath']),
|
|
1904
|
+
`${gitOutput(target, ['rev-parse', '--git-common-dir']) || '.git'}/hooks`,
|
|
1905
|
+
].flatMap((dir) => {
|
|
1906
|
+
if (!dir || dir === '.githooks' || dir === './.githooks') return []
|
|
1907
|
+
return [isAbsolute(dir) ? dir : join(target, dir)]
|
|
1908
|
+
})
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* The delegating stub for one machine-level hook name. Self-contained: the
|
|
1913
|
+
* machine's hooks directories are re-probed at run time so the committed
|
|
1914
|
+
* templates never bake in machine-specific paths.
|
|
1915
|
+
*
|
|
1916
|
+
* @param {string} name
|
|
1917
|
+
*/
|
|
1918
|
+
const machineDelegateStub = (name) => `#!/usr/bin/env sh
|
|
1919
|
+
# Generated by Code Foundry — delegates ${name} to the machine-level hook so
|
|
1920
|
+
# enabling .githooks keeps secret guards, git-lfs, and other machine hooks
|
|
1921
|
+
# working. Discovery mirrors .githooks/pre-commit; rerun sync after changing
|
|
1922
|
+
# the machine's hooks.
|
|
1923
|
+
set -eu
|
|
1924
|
+
machine_hook() {
|
|
1925
|
+
hook_name="$1"
|
|
1926
|
+
for dir in \\
|
|
1927
|
+
"$(git config --get code-foundry.previousHooksPath 2>/dev/null || true)" \\
|
|
1928
|
+
"$(git config --global --get core.hooksPath 2>/dev/null || true)" \\
|
|
1929
|
+
"$(git rev-parse --git-common-dir 2>/dev/null || printf '.git')/hooks"
|
|
1930
|
+
do
|
|
1931
|
+
case "$dir" in
|
|
1932
|
+
'' | .githooks | ./.githooks | */.githooks) continue ;;
|
|
1933
|
+
esac
|
|
1934
|
+
candidate="$dir/$hook_name"
|
|
1935
|
+
if [ -f "$candidate" ] && ! [ "$candidate" -ef "$0" ]; then
|
|
1936
|
+
printf '%s' "$candidate"
|
|
1937
|
+
return 0
|
|
1938
|
+
fi
|
|
1939
|
+
done
|
|
1940
|
+
return 1
|
|
1941
|
+
}
|
|
1942
|
+
if [ -n "\${CODE_FOUNDRY_HOOK_CHAIN:-}" ]; then
|
|
1943
|
+
exit 0
|
|
1944
|
+
fi
|
|
1945
|
+
if machine="$(machine_hook ${name})"; then
|
|
1946
|
+
# The marker makes a machine hook that delegates back to .githooks settle
|
|
1947
|
+
# here instead of looping.
|
|
1948
|
+
if [ -x "$machine" ]; then
|
|
1949
|
+
CODE_FOUNDRY_HOOK_CHAIN=1 exec "$machine" "$@"
|
|
1950
|
+
else
|
|
1951
|
+
CODE_FOUNDRY_HOOK_CHAIN=1 exec sh "$machine" "$@"
|
|
1952
|
+
fi
|
|
1953
|
+
fi
|
|
1954
|
+
exit 0
|
|
1955
|
+
`
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* Write delegating stubs into .githooks for every machine-level hook this
|
|
1959
|
+
* repository does not own, and remove stubs the machine no longer backs. The
|
|
1960
|
+
* stubs are machine-local (gitignored): they are rewritten on every sync and
|
|
1961
|
+
* only ever removed when their content carries this generator's marker.
|
|
1962
|
+
*
|
|
1963
|
+
* @param {string} target @param {string} previousHooksPath
|
|
1964
|
+
*/
|
|
1965
|
+
function syncMachineHookDelegates(target, previousHooksPath) {
|
|
1966
|
+
const directories = machineHooksDirectories(target, previousHooksPath)
|
|
1967
|
+
/** @param {string} name */
|
|
1968
|
+
const machineHook = (name) => {
|
|
1969
|
+
for (const dir of directories) {
|
|
1970
|
+
const candidate = join(dir, name)
|
|
1971
|
+
try {
|
|
1972
|
+
if (statSync(candidate).isFile()) return candidate
|
|
1973
|
+
} catch {
|
|
1974
|
+
// Absent from this directory; keep probing.
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return null
|
|
1978
|
+
}
|
|
1979
|
+
mkdirSync(join(target, '.githooks'), { recursive: true })
|
|
1980
|
+
for (const name of MACHINE_DELEGATED_HOOKS) {
|
|
1981
|
+
const stubPath = join(target, '.githooks', name)
|
|
1982
|
+
if (machineHook(name)) {
|
|
1983
|
+
writeFileSync(stubPath, machineDelegateStub(name))
|
|
1984
|
+
chmodSync(stubPath, 0o755)
|
|
1985
|
+
} else if (
|
|
1986
|
+
existsSync(stubPath) &&
|
|
1987
|
+
readFileSync(stubPath, 'utf8').includes('Generated by Code Foundry')
|
|
1988
|
+
) {
|
|
1989
|
+
rmSync(stubPath)
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1851
1994
|
/** @param {string} file @param {Buffer|string} content @param {boolean} dryRun */
|
|
1852
1995
|
function writeOrReport(file, content, dryRun) {
|
|
1853
1996
|
if (dryRun) {
|
package/src/lib/pre-commit.mjs
CHANGED
|
@@ -98,6 +98,25 @@ export function preCommitBuildEnabled(value) {
|
|
|
98
98
|
return resolved === 'true'
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The one-line explanation printed when lint or type-check fails while the
|
|
103
|
+
* commit-time build is disabled. CI builds before those tasks, so a fresh
|
|
104
|
+
* clone can fail the gate against an unmodified tree — a bare ENOENT trains
|
|
105
|
+
* `--no-verify` unless the gate names the actual problem.
|
|
106
|
+
*
|
|
107
|
+
* @param {{ build: boolean, buildScript: boolean, packageManager: string | null }} options
|
|
108
|
+
* @returns {string | undefined} The hint, or undefined when it cannot apply.
|
|
109
|
+
*/
|
|
110
|
+
export function freshCloneBuildHint({ build, buildScript, packageManager }) {
|
|
111
|
+
if (build || !buildScript) return undefined
|
|
112
|
+
const runner = packageManager ?? 'npm'
|
|
113
|
+
return (
|
|
114
|
+
`note: these checks may be reading missing build output on a fresh clone; ` +
|
|
115
|
+
`run "${runner} run build" once, or set pre_commit_build: true in ` +
|
|
116
|
+
`.github/code-foundry.yml to build before lint and type-check`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
/**
|
|
102
121
|
* Split a file list so one tool invocation never exceeds the platform's
|
|
103
122
|
* argument-length limit on very large commits.
|
package/src/runtime-core.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
parseStagedFiles,
|
|
26
26
|
planPreCommit,
|
|
27
27
|
preCommitBuildEnabled,
|
|
28
|
+
freshCloneBuildHint,
|
|
28
29
|
} from './lib/pre-commit.mjs'
|
|
29
30
|
import { runNodePackagePerformance } from './lib/node-package-performance.mjs'
|
|
30
31
|
import {
|
|
@@ -428,16 +429,20 @@ function commandExists(command) {
|
|
|
428
429
|
return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0
|
|
429
430
|
}
|
|
430
431
|
|
|
431
|
-
/** @param {string} command @param {string[]} [args] @param {
|
|
432
|
+
/** @param {string} command @param {string[]} [args] @param {{ onFailure?: string }} [options] */
|
|
432
433
|
function run(command, args = [], options = {}) {
|
|
434
|
+
const { onFailure, ...spawnOptions } = options
|
|
433
435
|
const result = spawnSync(command, args, {
|
|
434
436
|
cwd: root,
|
|
435
437
|
stdio: 'inherit',
|
|
436
438
|
env: process.env,
|
|
437
|
-
...
|
|
439
|
+
...spawnOptions,
|
|
438
440
|
})
|
|
439
441
|
if (result.error) throw result.error
|
|
440
|
-
if (result.status !== 0)
|
|
442
|
+
if (result.status !== 0) {
|
|
443
|
+
if (onFailure) console.error(onFailure)
|
|
444
|
+
process.exit(result.status ?? 1)
|
|
445
|
+
}
|
|
441
446
|
}
|
|
442
447
|
|
|
443
448
|
// pip-audit can report its clean result with a non-zero status when the
|
|
@@ -494,28 +499,28 @@ function packageCommand(args) {
|
|
|
494
499
|
}
|
|
495
500
|
}
|
|
496
501
|
|
|
497
|
-
/** @param {string[]} names */
|
|
498
|
-
function runScript(names) {
|
|
502
|
+
/** @param {string[]} names @param {{ onFailure?: string }} [options] */
|
|
503
|
+
function runScript(names, options = {}) {
|
|
499
504
|
const name = names.find((candidate) => hasScript(candidate))
|
|
500
505
|
if (!name) return false
|
|
501
506
|
const [manager, args] = packageCommand(['run', name])
|
|
502
507
|
if (!manager) return false
|
|
503
|
-
run(manager, args)
|
|
508
|
+
run(manager, args, options)
|
|
504
509
|
return true
|
|
505
510
|
}
|
|
506
511
|
|
|
507
|
-
/** @param {string} tool @param {string[]} [args] */
|
|
508
|
-
function runTool(tool, args = []) {
|
|
512
|
+
/** @param {string} tool @param {string[]} [args] @param {{ onFailure?: string }} [options] */
|
|
513
|
+
function runTool(tool, args = [], options = {}) {
|
|
509
514
|
if (['ruff', 'pytest', 'pylint'].includes(tool)) {
|
|
510
515
|
const venvTool = resolve(root, `.venv/bin/${tool}`)
|
|
511
|
-
if (existsSync(venvTool)) return run(venvTool, args)
|
|
516
|
+
if (existsSync(venvTool)) return run(venvTool, args, options)
|
|
512
517
|
}
|
|
513
518
|
const [manager] = packageCommand([])
|
|
514
|
-
if (manager === 'bun') return run('bunx', ['--no-install', tool, ...args])
|
|
515
|
-
if (manager === 'pnpm') return run('pnpm', ['exec', tool, ...args])
|
|
516
|
-
if (manager === 'yarn') return run('yarn', ['exec', tool, ...args])
|
|
517
|
-
if (manager === 'npm') return run('npx', ['--no-install', tool, ...args])
|
|
518
|
-
return run(tool, args)
|
|
519
|
+
if (manager === 'bun') return run('bunx', ['--no-install', tool, ...args], options)
|
|
520
|
+
if (manager === 'pnpm') return run('pnpm', ['exec', tool, ...args], options)
|
|
521
|
+
if (manager === 'yarn') return run('yarn', ['exec', tool, ...args], options)
|
|
522
|
+
if (manager === 'npm') return run('npx', ['--no-install', tool, ...args], options)
|
|
523
|
+
return run(tool, args, options)
|
|
519
524
|
}
|
|
520
525
|
|
|
521
526
|
function install() {
|
|
@@ -1193,9 +1198,11 @@ function runToolOnFiles(tool, args, files) {
|
|
|
1193
1198
|
*
|
|
1194
1199
|
* The gate is change-aware: it formats and lints only staged files, runs the
|
|
1195
1200
|
* project's type-check command only when typed sources or compiler config are
|
|
1196
|
-
* staged, and never builds unless `pre_commit_build: true` opts back in
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1201
|
+
* staged, and never builds unless `pre_commit_build: true` opts back in — in
|
|
1202
|
+
* which case the build runs before lint and type-check, mirroring CI's
|
|
1203
|
+
* ordering, because those tasks read built package output. The full suite
|
|
1204
|
+
* still runs in CI. Tools see working-tree contents, so a partially staged
|
|
1205
|
+
* file is checked as it exists on disk.
|
|
1199
1206
|
*/
|
|
1200
1207
|
export function preCommit() {
|
|
1201
1208
|
const staged = parseStagedFiles(
|
|
@@ -1225,9 +1232,22 @@ export function preCommit() {
|
|
|
1225
1232
|
runScript(['format:check', 'format', 'fmt'])
|
|
1226
1233
|
}
|
|
1227
1234
|
}
|
|
1235
|
+
// Mirror CI's prerequisite ordering: lint, boundary, and type-check tasks
|
|
1236
|
+
// read built package output, so the opt-in build runs before them. Format
|
|
1237
|
+
// stays first for the fastest feedback on the most common failure. Without
|
|
1238
|
+
// the opt-in, a failure that CI (which builds first) cannot reproduce gets
|
|
1239
|
+
// the one-line explanation instead of training `--no-verify`.
|
|
1240
|
+
if (plan.build) ci('build')
|
|
1241
|
+
const buildHint = freshCloneBuildHint({
|
|
1242
|
+
build: plan.build,
|
|
1243
|
+
buildScript: hasScript('build'),
|
|
1244
|
+
packageManager: packageCommand([])[0],
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1228
1247
|
if (js && (plan.lintAll || plan.lintFiles.length)) {
|
|
1229
1248
|
if (plan.lintAll) {
|
|
1230
|
-
if (!runScript(['lint']) && hasOxlintSetup())
|
|
1249
|
+
if (!runScript(['lint'], { onFailure: buildHint }) && hasOxlintSetup())
|
|
1250
|
+
runTool('oxlint', ['--deny-warnings'])
|
|
1231
1251
|
} else if (hasOxlintSetup()) {
|
|
1232
1252
|
runToolOnFiles(
|
|
1233
1253
|
'oxlint',
|
|
@@ -1241,8 +1261,11 @@ export function preCommit() {
|
|
|
1241
1261
|
if (js && plan.typeCheck) {
|
|
1242
1262
|
// Use the project's own command so project references, workspaces, and
|
|
1243
1263
|
// path aliases resolve exactly as they do in CI.
|
|
1244
|
-
const scripted = runScript(['type-check', 'typecheck', 'type:check']
|
|
1245
|
-
|
|
1264
|
+
const scripted = runScript(['type-check', 'typecheck', 'type:check'], {
|
|
1265
|
+
onFailure: buildHint,
|
|
1266
|
+
})
|
|
1267
|
+
if (!scripted && existsSync(resolve(root, 'tsconfig.json')))
|
|
1268
|
+
runTool('tsc', ['--noEmit'], { onFailure: buildHint })
|
|
1246
1269
|
}
|
|
1247
1270
|
if (plan.rust) {
|
|
1248
1271
|
run('cargo', ['fmt', '--check'])
|
|
@@ -1255,7 +1278,6 @@ export function preCommit() {
|
|
|
1255
1278
|
runToolOnFiles('ruff', ['format', '--check', '--force-exclude'], plan.pythonFiles)
|
|
1256
1279
|
runToolOnFiles('ruff', ['check', '--force-exclude'], plan.pythonFiles)
|
|
1257
1280
|
}
|
|
1258
|
-
if (plan.build) ci('build')
|
|
1259
1281
|
}
|
|
1260
1282
|
|
|
1261
1283
|
// Only dispatch when this module is the process entry point. The CLI imports
|
package/src/templates/gitignore
CHANGED