code-foundry 1.43.1 → 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,19 @@
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
+
10
+ ## [1.43.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.1...v1.43.2) (2026-10-03)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * require candidate asset qualification before stable promotion ([#705](https://github.com/0xPlayerOne/code-foundry/issues/705)) ([f42e4f5](https://github.com/0xPlayerOne/code-foundry/commit/f42e4f5fb063ea22fa25791a13c75c90d41bf17a))
16
+
3
17
  ## [1.43.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.0...v1.43.1) (2026-10-03)
4
18
 
5
19
 
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
@@ -115,29 +115,31 @@ See [Merge queue validation](merge-queues.md) before enabling it.
115
115
 
116
116
  ## Validation and quality
117
117
 
118
- | Key | Values | Purpose |
119
- | ----------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
- | `performance` | `auto`, `true`, `false` | Discover, require, or disable performance checks. |
121
- | `performance_command` | JSON argv array or array of argv arrays | Ordered commands for a non-package performance harness. |
122
- | `performance_profile` | empty or `node-package` | Shared package import, memory, archive, and dependency audit. |
123
- | `performance_budget_file` | repository-relative path | Budget file for `node-package`; defaults to `performance-package-budgets.json`. |
124
- | `eval` | `auto`, `true`, `false` | Discover, require, or disable the deterministic eval tier. |
125
- | `eval_command` | JSON argv array | Explicit harness command when there is no `eval` package script. |
126
- | `eval_report_file` | repository-relative path | Report validated against the eval contract; defaults to `eval-results/result.json`. |
127
- | `eval_budget_file` | repository-relative path | Optional budget file; defaults to `eval-budgets.json`. |
128
- | `eval_runner` | runner label | Runner for the `Validation / Eval` lane; defaults to the repository runner. Browser evals need a Chrome-capable runner such as `ubuntu-latest`. |
129
- | `required_capabilities` | comma-separated task names | Fail closed when a required task or coverage evidence is unavailable. |
130
- | `coverage_enforcement` | `auto`, `required`, `off` | Shared coverage-report policy. |
131
- | `coverage_minimum` | `0`–`100` | Minimum percentage; defaults to `80`. |
132
- | `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the coverage gate. |
133
- | `coverage_report` | comma-separated repository paths | Istanbul JSON summary or LCOV evidence files. |
134
- | `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
135
- | `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
136
- | `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
137
- | `e2e_shards` | `1`–`8` | Fan the `Validation / Test / E2E` job into one runner per shard. The repository's `test:e2e`/`e2e` script receives `E2E_SHARD_INDEX` and `E2E_TOTAL_SHARDS` and partitions itself (Playwright takes `--shard=$E2E_SHARD_INDEX/$E2E_TOTAL_SHARDS`); each shard owns a runner-local test database, so shards stay isolated. |
138
- | `release_batching_schedule` | five-field numeric cron | Batch Release Please into scheduled windows. Ordinary pushes skip the producer; release-PR squashes and manual dispatches still run. Batched releases are created privately as drafts and published with prerelease visibility atomically. Manual dispatches publish stable hotfixes. Absent, releases publish per merge. Requires a single root Release Please package. |
139
- | `release_batching_prerelease` | `true`, `false` | Defaults to `true` with a schedule. Sync enables `draft` and `force-tag-creation` in Release Please; the producer publishes the draft with the selected visibility. `false` keeps scheduled releases stable-visible. |
140
- | `release_batching_soak_hours` | `1`–`336` | Generate a daily promoter that promotes the newest prerelease when the oldest candidate since the last stable has soaked this many hours, stitching batch notes. Requires a batching schedule and prerelease publication. Set `STABLE_PROMOTION_HELD=true` in repository Actions variables to hold scheduled and manual promotion; `force` overrides the hold or soak, and `version` selects a plain semantic version. |
118
+ | Key | Values | Purpose |
119
+ | ----------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `performance` | `auto`, `true`, `false` | Discover, require, or disable performance checks. |
121
+ | `performance_command` | JSON argv array or array of argv arrays | Ordered commands for a non-package performance harness. |
122
+ | `performance_profile` | empty or `node-package` | Shared package import, memory, archive, and dependency audit. |
123
+ | `performance_budget_file` | repository-relative path | Budget file for `node-package`; defaults to `performance-package-budgets.json`. |
124
+ | `eval` | `auto`, `true`, `false` | Discover, require, or disable the deterministic eval tier. |
125
+ | `eval_command` | JSON argv array | Explicit harness command when there is no `eval` package script. |
126
+ | `eval_report_file` | repository-relative path | Report validated against the eval contract; defaults to `eval-results/result.json`. |
127
+ | `eval_budget_file` | repository-relative path | Optional budget file; defaults to `eval-budgets.json`. |
128
+ | `eval_runner` | runner label | Runner for the `Validation / Eval` lane; defaults to the repository runner. Browser evals need a Chrome-capable runner such as `ubuntu-latest`. |
129
+ | `required_capabilities` | comma-separated task names | Fail closed when a required task or coverage evidence is unavailable. |
130
+ | `coverage_enforcement` | `auto`, `required`, `off` | Shared coverage-report policy. |
131
+ | `coverage_minimum` | `0`–`100` | Minimum percentage; defaults to `80`. |
132
+ | `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the coverage gate. |
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). |
135
+ | `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
136
+ | `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
137
+ | `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
138
+ | `e2e_shards` | `1`–`8` | Fan the `Validation / Test / E2E` job into one runner per shard. The repository's `test:e2e`/`e2e` script receives `E2E_SHARD_INDEX` and `E2E_TOTAL_SHARDS` and partitions itself (Playwright takes `--shard=$E2E_SHARD_INDEX/$E2E_TOTAL_SHARDS`); each shard owns a runner-local test database, so shards stay isolated. |
139
+ | `release_batching_schedule` | five-field numeric cron | Batch Release Please into scheduled windows. Ordinary pushes skip the producer; release-PR squashes and manual dispatches still run. Batched releases are created privately as drafts and published with prerelease visibility atomically. Manual dispatches publish stable hotfixes. Absent, releases publish per merge. Requires a single root Release Please package. |
140
+ | `release_batching_prerelease` | `true`, `false` | Defaults to `true` with a schedule. Sync enables `draft` and `force-tag-creation` in Release Please; the producer publishes the draft with the selected visibility. `false` keeps scheduled releases stable-visible. |
141
+ | `release_batching_soak_hours` | `1`–`336` | Generate a daily promoter that promotes the newest prerelease when the oldest candidate since the last stable has soaked this many hours, stitching batch notes. Requires a batching schedule and prerelease publication. Set `STABLE_PROMOTION_HELD=true` in repository Actions variables to hold scheduled and manual promotion; `force` overrides the hold or soak, and `version` selects a plain semantic version. |
142
+ | `release_batching_qualification_workflow` | workflow filename (for example `release-assets.yml`) | Require the latest release-event run of this workflow for the candidate tag’s exact source commit to succeed before stable promotion. Requires a batching soak. Running, missing, failed, or cancelled runs hold promotion, including named and forced promotions; `force` only bypasses hold and soak. The promoter receives read-only Actions access. |
141
143
 
142
144
  Supported task capabilities are `format`, `lint`, `type_check`, `build`, `unit`,
143
145
  `integration`, `e2e`, `smoke`, `eval`, and `performance`. `coverage` is a policy
@@ -168,6 +170,36 @@ to keep discovery optional. Product-quality profiles are repository-owned
168
170
  manifests invoked by existing build or E2E commands; they are not activated by a
169
171
  configuration key. See [Product quality profiles](product-quality.md).
170
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
+
171
203
  ## Release and branch policy
172
204
 
173
205
  | Key | Values | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.43.1",
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": {
@@ -754,6 +754,7 @@ on:
754
754
 
755
755
  permissions:
756
756
  contents: write
757
+ actions: read
757
758
 
758
759
  concurrency:
759
760
  group: promote-stable
@@ -769,6 +770,7 @@ jobs:
769
770
  env:
770
771
  GH_TOKEN: \${{ github.token }}
771
772
  SOAK_HOURS: '__SOAK_HOURS__'
773
+ QUALIFICATION_WORKFLOW: '__QUALIFICATION_WORKFLOW__'
772
774
  VERSION: \${{ inputs.version }}
773
775
  FORCE: \${{ inputs.force }}
774
776
  STABLE_PROMOTION_HELD: \${{ vars.STABLE_PROMOTION_HELD }}
@@ -777,6 +779,19 @@ jobs:
777
779
  repo="$GITHUB_REPOSITORY"
778
780
  summary="$GITHUB_STEP_SUMMARY"
779
781
 
782
+ # Force bypasses hold and soak, but never asset qualification.
783
+ require_qualification() {
784
+ [ -z "$QUALIFICATION_WORKFLOW" ] && return 0
785
+ local candidate_sha conclusion
786
+ candidate_sha="$(gh api "repos/$repo/commits/$1" --jq '.sha')" || return 1
787
+ [ -n "$candidate_sha" ] && [ "$candidate_sha" != "null" ] || return 1
788
+ conclusion="$(gh run list --repo "$repo" --workflow "$QUALIFICATION_WORKFLOW" --event release --commit "$candidate_sha" --limit 1 --json conclusion --jq '.[0].conclusion // "missing"')" || return 1
789
+ if [ "$conclusion" != "success" ]; then
790
+ echo "Asset qualification for $1 is $conclusion; holding stable promotion." | tee -a "$summary"
791
+ return 1
792
+ fi
793
+ }
794
+
780
795
  # The repository variable applies equally to scheduled and manual runs.
781
796
  if [ "$STABLE_PROMOTION_HELD" = "true" ] && [ "$FORCE" != "true" ]; then
782
797
  echo "Stable promotion is held." | tee -a "$summary"
@@ -788,6 +803,10 @@ jobs:
788
803
  echo "::error::version must be a plain semantic version" >&2
789
804
  exit 1
790
805
  fi
806
+ if ! require_qualification "v$VERSION"; then
807
+ echo "Qualification unavailable or unsuccessful; holding v$VERSION." | tee -a "$summary"
808
+ exit 0
809
+ fi
791
810
  gh release edit "v$VERSION" --repo "$repo" --prerelease=false --latest
792
811
  echo "Promoted v$VERSION to stable by name." | tee -a "$summary"
793
812
  exit 0
@@ -833,6 +852,11 @@ jobs:
833
852
  exit 0
834
853
  fi
835
854
 
855
+ if ! require_qualification "$tip"; then
856
+ echo "Qualification unavailable or unsuccessful; holding $tip." | tee -a "$summary"
857
+ exit 0
858
+ fi
859
+
836
860
  # Stitch the batch's notes oldest→newest so the stable release
837
861
  # documents everything stable users are getting, not just the tip.
838
862
  jq -r '.[] | "## " + .tag + "\\n\\n" + (.body // "")' <<<"$batch" > batch-notes.md
@@ -878,6 +902,16 @@ function validateReleaseBatchingConfig(config) {
878
902
  `Unsupported release_batching_prerelease: ${config.release_batching_prerelease}; use true or false.`
879
903
  )
880
904
  }
905
+ if (config.release_batching_qualification_workflow) {
906
+ if (
907
+ !/^[A-Za-z0-9_.-]+\.ya?ml$/.test(config.release_batching_qualification_workflow) ||
908
+ !config.release_batching_soak_hours
909
+ ) {
910
+ throw new Error(
911
+ 'Unsupported release_batching_qualification_workflow: use a workflow filename with a batching soak.'
912
+ )
913
+ }
914
+ }
881
915
  if (config.release_batching_soak_hours) {
882
916
  const hours = Number(config.release_batching_soak_hours)
883
917
  if (!Number.isInteger(hours) || hours < 1 || hours > 336) {
@@ -902,7 +936,10 @@ function validateReleaseBatchingConfig(config) {
902
936
  export function renderPromoteStable(config) {
903
937
  const soakHours = Number(config.release_batching_soak_hours ?? '')
904
938
  if (!Number.isInteger(soakHours) || soakHours < 1 || soakHours > 336) return null
905
- return PROMOTE_STABLE_TEMPLATE.replaceAll('__SOAK_HOURS__', String(soakHours))
939
+ return PROMOTE_STABLE_TEMPLATE.replaceAll('__SOAK_HOURS__', String(soakHours)).replaceAll(
940
+ '__QUALIFICATION_WORKFLOW__',
941
+ config.release_batching_qualification_workflow ?? ''
942
+ )
906
943
  }
907
944
 
908
945
  /**
@@ -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