code-foundry 1.43.2 → 1.44.0

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/AGENTS.md CHANGED
@@ -211,6 +211,14 @@ At minimum:
211
211
 
212
212
  If a check cannot run, state the exact reason. A skipped check is not a passing check.
213
213
 
214
+ The generated `.githooks/pre-commit` gate is change-aware so parallel work on a
215
+ shared checkout is not serialized behind repository-wide checks. It blocks
216
+ whitespace errors, formats and lints only staged files, runs the project's
217
+ type-check command only when typed sources or compiler configuration are
218
+ staged, and does not build unless `pre_commit_build: true` is set. Passing the
219
+ hook is not full validation: run the broader checks above before opening or
220
+ readying a pull request.
221
+
214
222
  <!-- /code-foundry-managed: validation -->
215
223
 
216
224
  <!-- code-foundry-managed: tests-and-coverage -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.44.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.2...v1.44.0) (2026-10-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
3
10
  ## [1.43.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.1...v1.43.2) (2026-10-03)
4
11
 
5
12
 
package/README.md CHANGED
@@ -44,8 +44,9 @@ contract. For normal updates, edit that file and run `npx code-foundry sync`.
44
44
  outcomes comparable across revisions and executors.
45
45
  - An opt-in product-quality runner for static sites, web apps, Workers, and
46
46
  published packages.
47
- - A small `.githooks/pre-commit` launcher with language-aware formatting and
48
- linting.
47
+ - A small `.githooks/pre-commit` launcher with a change-aware gate: it formats
48
+ and lints only staged files, type-checks only when typed sources are staged,
49
+ and leaves the build to CI.
49
50
  - An optional `.mise.toml`/`mise.lock`, repository profile configuration, and
50
51
  standard GitHub forms and policy files.
51
52
  - AGENTS instructions, CODEOWNERS, license/notice files, and reusable release
@@ -131,6 +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
135
  | `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
135
136
  | `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
136
137
  | `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
@@ -169,6 +170,36 @@ to keep discovery optional. Product-quality profiles are repository-owned
169
170
  manifests invoked by existing build or E2E commands; they are not activated by a
170
171
  configuration key. See [Product quality profiles](product-quality.md).
171
172
 
173
+ ### Pre-commit gate
174
+
175
+ `code-foundry sync` installs `.githooks/pre-commit`, which runs
176
+ `code-foundry pre-commit` when the repository depends on `code-foundry`. The
177
+ gate is change-aware and never runs work for code the commit cannot affect:
178
+
179
+ - With nothing staged it exits immediately. `git diff --cached --check` still
180
+ blocks whitespace errors in every staged change.
181
+ - Oxfmt and Oxlint check only the staged files they support, honoring their
182
+ own ignore patterns. Staging `.oxfmtrc.json`, `.oxlintrc.json`, or their
183
+ `*.config.*` equivalents widens that tool to the whole repository through the
184
+ repository's `format:check`/`lint` script. A repository without Oxfmt or
185
+ Oxlint keeps its own script, which runs only when a matching file is staged.
186
+ - The project's `type-check`/`typecheck`/`type:check` script (or `tsc --noEmit`)
187
+ runs only when `.ts`, `.tsx`, `.mts`, `.cts`, or `tsconfig*.json` files are
188
+ staged, and for JavaScript sources when the repository type-checks them with
189
+ a root `jsconfig.json` or `checkJs`.
190
+ - Ruff checks only staged Python files (`--force-exclude`), or the whole
191
+ repository when `pyproject.toml`, `ruff.toml`, or a requirements file is
192
+ staged. Staged Rust sources or `Cargo.toml` run `cargo fmt --check` and
193
+ Clippy.
194
+ - The build does not run at commit time; CI and explicit commands own it. Set
195
+ `pre_commit_build: true` to run the `build` script (or `cargo build`) when
196
+ code is staged.
197
+
198
+ Tools read working-tree contents, so a partially staged file is checked as it
199
+ exists on disk. Task scripts that declare dependencies of their own, such as a
200
+ Turborepo `typecheck` task with `dependsOn: ["^build"]`, still run them; drop
201
+ that dependency to keep type checks build-free.
202
+
172
203
  ## Release and branch policy
173
204
 
174
205
  | Key | Values | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.43.2",
3
+ "version": "1.44.0",
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": {
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Change-aware planning for the local commit gate.
5
+ *
6
+ * The gate runs once per commit and blocks every agent or developer sharing a
7
+ * checkout, so it only does work the staged change can affect: format and lint
8
+ * the staged files, type-check only when a typed source or compiler config is
9
+ * staged, and leave the build to CI unless a repository opts back in with
10
+ * `pre_commit_build: true`. Repository-wide runs happen only when a staged file
11
+ * changes the tool's own configuration, because that can affect every file.
12
+ */
13
+
14
+ /** Files Oxfmt formats; anything else is left out of the staged file list. */
15
+ const FORMATTABLE =
16
+ /\.(?:[cm]?[jt]sx?|json[c5]?|mdx?|ya?ml|toml|css|scss|less|html?|vue|svelte|astro|graphql|gql|hbs|handlebars)$/i
17
+ /** Files Oxlint lints. */
18
+ const LINTABLE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/i
19
+ /** Typed sources and compiler configuration: the type-check trigger. */
20
+ const TYPED = /\.(?:ts|tsx|mts|cts)$|(?:^|\/)tsconfig[^/]*\.json$/i
21
+ /** JavaScript sources, type-checked only when the repository opts in with a root jsconfig. */
22
+ const JAVASCRIPT = /\.(?:js|jsx|mjs|cjs)$|(?:^|\/)jsconfig[^/]*\.json$/i
23
+ const FORMAT_CONFIG = /^(?:\.oxfmtrc\.jsonc?|oxfmt\.config\.[^/]+|\.prettierignore)$/
24
+ const LINT_CONFIG = /^(?:\.oxlintrc\.json|oxlint\.config\.[^/]+)$/
25
+ const PYTHON = /\.pyi?$/i
26
+ const PYTHON_CONFIG =
27
+ /^(?:pyproject\.toml|ruff\.toml|\.ruff\.toml)$|(?:^|\/)requirements[^/]*\.txt$/
28
+ const RUST = /\.rs$|(?:^|\/)Cargo\.toml$/
29
+
30
+ /**
31
+ * @typedef {object} PreCommitPlan
32
+ * @property {boolean} formatAll A formatter configuration file is staged.
33
+ * @property {string[]} formatFiles Staged files the formatter should check.
34
+ * @property {boolean} lintAll A linter configuration file is staged.
35
+ * @property {string[]} lintFiles Staged files the linter should check.
36
+ * @property {boolean} typeCheck Run the project's type-check command.
37
+ * @property {boolean} pythonAll A Python tool configuration file is staged.
38
+ * @property {string[]} pythonFiles Staged Python files.
39
+ * @property {boolean} rust Rust sources or manifests are staged.
40
+ * @property {boolean} build Run the project's build command.
41
+ */
42
+
43
+ /**
44
+ * @param {string[]} staged Repository-relative paths of staged files that still exist.
45
+ * @param {{checkJs?: boolean, build?: boolean}} [options]
46
+ * @returns {PreCommitPlan}
47
+ */
48
+ export function planPreCommit(staged, options = {}) {
49
+ const formatAll = staged.some((file) => FORMAT_CONFIG.test(file))
50
+ const lintAll = staged.some((file) => LINT_CONFIG.test(file))
51
+ const pythonAll = staged.some((file) => PYTHON_CONFIG.test(file))
52
+ const formatFiles = staged.filter((file) => FORMATTABLE.test(file))
53
+ const lintFiles = staged.filter((file) => LINTABLE.test(file))
54
+ const pythonFiles = staged.filter((file) => PYTHON.test(file))
55
+ const typeCheck = staged.some(
56
+ (file) => TYPED.test(file) || (options.checkJs === true && JAVASCRIPT.test(file))
57
+ )
58
+ const rust = staged.some((file) => RUST.test(file))
59
+ const code =
60
+ typeCheck ||
61
+ lintFiles.length > 0 ||
62
+ pythonFiles.length > 0 ||
63
+ rust ||
64
+ staged.some((file) => /(?:^|\/)package\.json$/.test(file))
65
+ return {
66
+ formatAll,
67
+ formatFiles,
68
+ lintAll,
69
+ lintFiles,
70
+ typeCheck,
71
+ pythonAll,
72
+ pythonFiles,
73
+ rust,
74
+ build: options.build === true && code,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Parse `git diff --cached --name-only -z` output.
80
+ * @param {string} output
81
+ * @returns {string[]}
82
+ */
83
+ export function parseStagedFiles(output) {
84
+ return output.split('\0').filter(Boolean)
85
+ }
86
+
87
+ /**
88
+ * Resolve the `pre_commit_build` configuration value. Building at commit time
89
+ * is opt-in: it serializes parallel work on a shared checkout and CI rebuilds
90
+ * every change anyway.
91
+ * @param {string|undefined} value
92
+ * @returns {boolean}
93
+ */
94
+ export function preCommitBuildEnabled(value) {
95
+ const resolved = value === undefined || value === '' ? 'false' : value
96
+ if (resolved !== 'true' && resolved !== 'false')
97
+ throw new Error(`Unsupported pre_commit_build: ${resolved}; use true or false.`)
98
+ return resolved === 'true'
99
+ }
100
+
101
+ /**
102
+ * Split a file list so one tool invocation never exceeds the platform's
103
+ * argument-length limit on very large commits.
104
+ * @param {string[]} files
105
+ * @param {number} [size]
106
+ * @returns {string[][]}
107
+ */
108
+ export function chunkFiles(files, size = 200) {
109
+ /** @type {string[][]} */
110
+ const chunks = []
111
+ for (let index = 0; index < files.length; index += size)
112
+ chunks.push(files.slice(index, index + size))
113
+ return chunks
114
+ }
@@ -20,6 +20,12 @@ import { docsOnlyPullRequest } from './lib/docs-only.mjs'
20
20
  import { readTaskFilters, resolveChangedPaths, taskAffected } from './lib/task-filters.mjs'
21
21
  import { classifyValidationMode, evaluateValidationGate } from './lib/validation-policy.mjs'
22
22
  import { readReleaseConfig, validateGeneratedReleaseDiff } from './lib/release-policy.mjs'
23
+ import {
24
+ chunkFiles,
25
+ parseStagedFiles,
26
+ planPreCommit,
27
+ preCommitBuildEnabled,
28
+ } from './lib/pre-commit.mjs'
23
29
  import { runNodePackagePerformance } from './lib/node-package-performance.mjs'
24
30
  import {
25
31
  EVAL_BUDGET_FILE_DEFAULT,
@@ -1167,35 +1173,89 @@ function printProfile(command) {
1167
1173
  writeOutput('npm_publish', config.npm_publish ?? 'false')
1168
1174
  }
1169
1175
 
1176
+ /** Whether JavaScript sources are type-checked (a root jsconfig or `checkJs`). */
1177
+ function checksJavascript() {
1178
+ if (existsSync(resolve(root, 'jsconfig.json'))) return true
1179
+ return /"checkJs"\s*:\s*true/.test(readFileSafe(resolve(root, 'tsconfig.json')) ?? '')
1180
+ }
1181
+
1182
+ /** @param {string} tool @param {string[]} args @param {string[]} files */
1183
+ function runToolOnFiles(tool, args, files) {
1184
+ for (const chunk of chunkFiles(files)) runTool(tool, [...args, ...chunk])
1185
+ }
1186
+
1170
1187
  /**
1171
1188
  * The local commit gate.
1172
1189
  *
1173
1190
  * Exported so the generated `.githooks/pre-commit` can reach it: the hook is
1174
- * what turns this into an actual gate, and it used to run the whitespace
1175
- * check alone. The generated hook is the consumer-visible contract, so keep
1176
- * the two in step.
1191
+ * what turns this into an actual gate. The generated hook is the
1192
+ * consumer-visible contract, so keep the two in step.
1193
+ *
1194
+ * The gate is change-aware: it formats and lints only staged files, runs the
1195
+ * 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. The
1197
+ * full suite still runs in CI. Tools see working-tree contents, so a partially
1198
+ * staged file is checked as it exists on disk.
1177
1199
  */
1178
1200
  export function preCommit() {
1179
- const changed = capture('git', ['diff', '--cached', '--name-only'])
1180
- if (!changed) return
1201
+ const staged = parseStagedFiles(
1202
+ capture('git', ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'])
1203
+ )
1204
+ // Deletions still go through the whitespace guard; they need no tool run.
1205
+ if (!staged.length && !capture('git', ['diff', '--cached', '--name-only'])) return
1181
1206
  const check = spawnSync('git', ['diff', '--cached', '--check'], { cwd: root, stdio: 'inherit' })
1182
1207
  if (check.status !== 0) process.exit(check.status ?? 1)
1183
- if (/\.(js|jsx|ts|tsx|json|md|mdx|yml|yaml)$/.test(changed)) {
1184
- ci('format')
1185
- ci('lint')
1186
- // Type errors are the fastest check that still needs a full toolchain, and
1187
- // the most common reason a green-looking commit fails CI later. Run it
1188
- // here while the dependency graph is warm.
1189
- ci('type_check')
1208
+ if (!staged.length) return
1209
+
1210
+ const plan = planPreCommit(staged, {
1211
+ checkJs: checksJavascript(),
1212
+ build: preCommitBuildEnabled(config.pre_commit_build),
1213
+ })
1214
+ const js = (hasLanguage('typescript') || hasLanguage('javascript')) && hasRootJavascriptProject()
1215
+
1216
+ if (js && (plan.formatAll || plan.formatFiles.length)) {
1217
+ if (plan.formatAll) {
1218
+ if (!runScript(['format:check', 'format', 'fmt']) && hasOxfmtSetup())
1219
+ runTool('oxfmt', ['--check', '.'])
1220
+ } else if (hasOxfmtSetup()) {
1221
+ runToolOnFiles('oxfmt', ['--check', '--no-error-on-unmatched-pattern'], plan.formatFiles)
1222
+ } else if (plan.formatFiles.some((file) => /\.(?:[cm]?[jt]sx?|json|mdx?|ya?ml)$/.test(file))) {
1223
+ // A repository-owned formatter cannot be scoped generically; its script
1224
+ // still runs, but only when a file it plausibly owns is staged.
1225
+ runScript(['format:check', 'format', 'fmt'])
1226
+ }
1227
+ }
1228
+ if (js && (plan.lintAll || plan.lintFiles.length)) {
1229
+ if (plan.lintAll) {
1230
+ if (!runScript(['lint']) && hasOxlintSetup()) runTool('oxlint', ['--deny-warnings'])
1231
+ } else if (hasOxlintSetup()) {
1232
+ runToolOnFiles(
1233
+ 'oxlint',
1234
+ ['--deny-warnings', '--no-error-on-unmatched-pattern'],
1235
+ plan.lintFiles
1236
+ )
1237
+ } else {
1238
+ runScript(['lint'])
1239
+ }
1240
+ }
1241
+ if (js && plan.typeCheck) {
1242
+ // Use the project's own command so project references, workspaces, and
1243
+ // path aliases resolve exactly as they do in CI.
1244
+ const scripted = runScript(['type-check', 'typecheck', 'type:check'])
1245
+ if (!scripted && existsSync(resolve(root, 'tsconfig.json'))) runTool('tsc', ['--noEmit'])
1190
1246
  }
1191
- if (/\.rs$|(^|\/)Cargo\.toml$/.test(changed)) {
1247
+ if (plan.rust) {
1192
1248
  run('cargo', ['fmt', '--check'])
1193
1249
  run('cargo', ['clippy', '--all-targets', '--', '-D', 'warnings'])
1194
1250
  }
1195
- if (/\.py$|(^|\/)(pyproject\.toml|requirements[^/]*\.txt)$/.test(changed)) {
1251
+ if (plan.pythonAll) {
1196
1252
  runTool('ruff', ['format', '--check', '.'])
1197
1253
  runTool('ruff', ['check', '.'])
1254
+ } else if (plan.pythonFiles.length) {
1255
+ runToolOnFiles('ruff', ['format', '--check', '--force-exclude'], plan.pythonFiles)
1256
+ runToolOnFiles('ruff', ['check', '--force-exclude'], plan.pythonFiles)
1198
1257
  }
1258
+ if (plan.build) ci('build')
1199
1259
  }
1200
1260
 
1201
1261
  // Only dispatch when this module is the process entry point. The CLI imports