code-foundry 1.47.1 → 1.48.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.
@@ -115,17 +115,23 @@ If the worktree is dirty, stop and understand the existing changes before switch
115
115
 
116
116
  ## Local validation
117
117
 
118
- The repository runtime detects supported tools and skips checks that do not apply:
118
+ Use the commands declared in this repository's manifests and documentation, or
119
+ invoke the installed Code Foundry CLI. The shared runtime detects supported tools
120
+ and skips checks that do not apply:
119
121
 
120
122
  ```sh
121
123
  npx code-foundry doctor
122
- npm run format:check # or the package manager's equivalent
123
- npm run lint
124
- npm run type-check
125
- npm test
126
- Security and dependency audits run through the GitHub Security workflow.
124
+ npx code-foundry plan --tier fast
125
+ npx code-foundry check --tier fast
126
+ npx code-foundry check --tier audit
127
127
  ```
128
128
 
129
+ `doctor` diagnoses setup; `plan` discovers tasks without running them. `check`
130
+ runs the selected tier: `fast` includes CI tasks, unit tests and performance,
131
+ while `audit` adds integration, E2E and smoke.
132
+
133
+ Security and dependency audits run through the GitHub Security workflow.
134
+
129
135
  Run the checks relevant to the change. For a release or security-sensitive change, run the complete set. Record the commands and results in the pull request.
130
136
 
131
137
  <!-- /code-foundry-managed: local-validation -->
@@ -54,6 +54,10 @@ inputs:
54
54
  description: Cache local Turborepo artifacts; auto skips duplicate archives when TURBO_TOKEN enables remote caching.
55
55
  required: false
56
56
  default: auto
57
+ cache-vite-plus:
58
+ description: Cache the local Vite+ task cache (node_modules/.vite/task-cache); only applies when code-foundry.yml sets monorepo_tool to vite-plus. Use true/false to override.
59
+ required: false
60
+ default: auto
57
61
  task:
58
62
  description: Optional CI task used to scope test dependency installation.
59
63
  required: false
@@ -122,11 +126,11 @@ runs:
122
126
  NODE
123
127
  }
124
128
 
125
- script_uses_turbo() {
129
+ script_uses_runner() {
126
130
  [ -f package.json ] || return 1
127
- REPO_FOUNDRY_TURBO_TASK="$REPO_FOUNDRY_TASK" node <<'NODE'
131
+ REPO_FOUNDRY_RUNNER="$1" REPO_FOUNDRY_RUNNER_TASK="$REPO_FOUNDRY_TASK" node <<'NODE'
128
132
  const fs = require('node:fs');
129
- const task = process.env.REPO_FOUNDRY_TURBO_TASK || '';
133
+ const task = process.env.REPO_FOUNDRY_RUNNER_TASK || '';
130
134
  const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
131
135
  const scripts = packageJson.scripts || {};
132
136
  const candidates = {
@@ -141,8 +145,11 @@ runs:
141
145
  performance: ['performance:check', 'perf:check'],
142
146
  }[task] || [];
143
147
  const selected = candidates.find((name) => scripts[name]);
144
- const usesTurbo = Boolean(selected && /\bturbo(?:\s|$)/.test(scripts[selected]));
145
- process.exit(usesTurbo ? 0 : 1);
148
+ const pattern = process.env.REPO_FOUNDRY_RUNNER === 'vite-plus'
149
+ ? /(?:^|[\s;&|(])(?:vp\s+(?:-C\s+\S+\s+)?run|vpr)(?:\s|$)/
150
+ : /\bturbo(?:\s|$)/;
151
+ const usesRunner = Boolean(selected && pattern.test(scripts[selected]));
152
+ process.exit(usesRunner ? 0 : 1);
146
153
  NODE
147
154
  }
148
155
 
@@ -298,8 +305,18 @@ runs:
298
305
  echo 'rust=false' >> "$GITHUB_OUTPUT"
299
306
  fi
300
307
 
308
+ # Vite+ is strictly opt-in: only an explicit monorepo_tool: vite-plus
309
+ # selects it. Every other value, or none, keeps the Turbo behavior.
310
+ vite_plus=false
311
+ if [ -f .github/code-foundry.yml ] &&
312
+ grep -Eq "^monorepo_tool:\s*[\"']?vite-plus[\"']?\s*(#.*)?$" .github/code-foundry.yml; then
313
+ vite_plus=true
314
+ fi
315
+ printf 'vite_plus=%s\n' "$vite_plus" >> "$GITHUB_OUTPUT"
316
+
301
317
  turbo=false
302
- if [ -f turbo.json ] || [ -f turbo.jsonc ] || has_tracked_regex '(^|/)turbo\.jsonc?$'; then
318
+ if [ "$vite_plus" != true ] &&
319
+ { [ -f turbo.json ] || [ -f turbo.jsonc ] || has_tracked_regex '(^|/)turbo\.jsonc?$'; }; then
303
320
  turbo=true
304
321
  echo 'turbo=true' >> "$GITHUB_OUTPUT"
305
322
  else
@@ -342,11 +359,18 @@ runs:
342
359
  printf 'eslint_fallback=%s\n' "$eslint_fallback" >> "$GITHUB_OUTPUT"
343
360
 
344
361
  turbo_task=false
345
- if [ -z "$REPO_FOUNDRY_TASK" ] || script_uses_turbo; then
362
+ if [ -z "$REPO_FOUNDRY_TASK" ] || script_uses_runner turbo; then
346
363
  turbo_task=true
347
364
  fi
348
365
  printf 'turbo_task=%s\n' "$turbo_task" >> "$GITHUB_OUTPUT"
349
366
 
367
+ vite_plus_task=false
368
+ if [ "$vite_plus" = true ] &&
369
+ { [ -z "$REPO_FOUNDRY_TASK" ] || script_uses_runner vite-plus; }; then
370
+ vite_plus_task=true
371
+ fi
372
+ printf 'vite_plus_task=%s\n' "$vite_plus_task" >> "$GITHUB_OUTPUT"
373
+
350
374
  build=false
351
375
  if has_tracked_regex '(^|/)(next\.config\.[^/]*|vite\.config\.[^/]*|tsconfig[^/]*\.json)$'; then
352
376
  build=true
@@ -630,6 +654,7 @@ runs:
630
654
  path: |
631
655
  **/.next/cache
632
656
  **/node_modules/.vite
657
+ !**/node_modules/.vite/task-cache
633
658
  **/*.tsbuildinfo
634
659
  key: ${{ runner.os }}-javascript-build-${{ github.workflow }}-${{ github.job }}-${{ hashFiles('**/package.json', '**/bun.lock', '**/bun.lockb', '**/pnpm-lock.yaml', '**/yarn.lock', '**/package-lock.json', '**/next.config.*', '**/vite.config.*', '**/tsconfig*.json', '.mise.toml', 'mise.lock') }}
635
660
  # Keep primary saves isolated so parallel jobs cannot race. A cache
@@ -888,3 +913,76 @@ runs:
888
913
  ~/.cache/yarn
889
914
  ~/.local/share/pnpm/store
890
915
  key: ${{ runner.os }}-javascript-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/pnpm-lock.yaml', '**/yarn.lock', '**/package-lock.json') }}
916
+ # Restored last: Install can replace node_modules, and the node_modules
917
+ # archives above must not contain task output.
918
+ - name: Detect Vite+ toolchain
919
+ id: vite-plus-toolchain
920
+ if: >-
921
+ inputs.install == 'true' &&
922
+ inputs.install-javascript == 'true' &&
923
+ inputs.cache-vite-plus != 'false' &&
924
+ steps.profile.outputs.vite_plus == 'true' &&
925
+ steps.profile.outputs.vite_plus_task == 'true' &&
926
+ hashFiles('**/package.json') != ''
927
+ shell: bash
928
+ env:
929
+ REPO_FOUNDRY_RUNNER_OS: ${{ runner.os }}
930
+ REPO_FOUNDRY_RUNNER_ARCH: ${{ runner.arch }}
931
+ run: |
932
+ node --input-type=module <<'NODE'
933
+ import { appendFileSync, readFileSync } from 'node:fs'
934
+ import { execFileSync } from 'node:child_process'
935
+ import { createRequire } from 'node:module'
936
+ import { resolve } from 'node:path'
937
+ const clean = (value) => String(value).replace(/[^A-Za-z0-9._-]/g, '_')
938
+ const tryVersion = (command) => {
939
+ try {
940
+ return execFileSync(command, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
941
+ } catch {
942
+ return 'none'
943
+ }
944
+ }
945
+ let vitePlus = 'unknown'
946
+ try {
947
+ vitePlus = createRequire(resolve('package.json'))('vite-plus/package.json').version
948
+ } catch {
949
+ try {
950
+ vitePlus = JSON.parse(readFileSync('node_modules/vite-plus/package.json', 'utf8')).version
951
+ } catch {
952
+ // Not installed at the root; the namespace stays distinct.
953
+ }
954
+ }
955
+ // Vite Task does not fingerprint the Node or Bun binary, so partition
956
+ // by OS, architecture, and exact runtime and Vite+ versions.
957
+ const namespace = [
958
+ 'vite-plus-v1',
959
+ process.env.REPO_FOUNDRY_RUNNER_OS,
960
+ process.env.REPO_FOUNDRY_RUNNER_ARCH,
961
+ `node${process.version}`,
962
+ `bun${tryVersion('bun')}`,
963
+ `vp${vitePlus}`,
964
+ ].map(clean).join('-')
965
+ // VITE_CACHE_PATH relocates the cache; it must be absolute.
966
+ const custom = process.env.VITE_CACHE_PATH
967
+ const paths = custom
968
+ ? [resolve(custom)]
969
+ : ['node_modules/.vite/task-cache', '**/node_modules/.vite/task-cache']
970
+ // Without a resolvable version the namespace cannot isolate releases.
971
+ appendFileSync(process.env.GITHUB_OUTPUT, `cacheable=${vitePlus !== 'unknown'}\n`)
972
+ appendFileSync(process.env.GITHUB_OUTPUT, `namespace=${namespace}\n`)
973
+ appendFileSync(process.env.GITHUB_OUTPUT, `cache_paths<<CACHE_PATHS_EOF\n${paths.join('\n')}\nCACHE_PATHS_EOF\n`)
974
+ console.log(`Vite+ cache namespace: ${namespace}`)
975
+ NODE
976
+ - name: Cache Vite+ tasks
977
+ if: steps.vite-plus-toolchain.outputs.cacheable == 'true'
978
+ uses: ./.github/actions/cache
979
+ with:
980
+ save: ${{ inputs.cache-save }}
981
+ path: ${{ steps.vite-plus-toolchain.outputs.cache_paths }}
982
+ # The run/attempt suffix saves a refreshed entry on every push; restore
983
+ # keys never leave the compatibility namespace.
984
+ key: ${{ steps.vite-plus-toolchain.outputs.namespace }}-${{ github.workflow }}-${{ github.job }}-${{ hashFiles('**/package.json', '**/bun.lock', '**/bun.lockb', '**/pnpm-lock.yaml', '**/yarn.lock', '**/package-lock.json', '.mise.toml', 'mise.lock') }}-${{ hashFiles('**/vite.config.*') }}-${{ github.run_id }}-${{ github.run_attempt }}
985
+ restore-keys: |
986
+ ${{ steps.vite-plus-toolchain.outputs.namespace }}-${{ github.workflow }}-${{ github.job }}-${{ hashFiles('**/package.json', '**/bun.lock', '**/bun.lockb', '**/pnpm-lock.yaml', '**/yarn.lock', '**/package-lock.json', '.mise.toml', 'mise.lock') }}-
987
+ ${{ steps.vite-plus-toolchain.outputs.namespace }}-${{ github.workflow }}-${{ github.job }}-
988
+ ${{ steps.vite-plus-toolchain.outputs.namespace }}-
@@ -183,6 +183,9 @@ jobs:
183
183
  type-check:
184
184
  name: Type-Check
185
185
  if: vars.CI_BILLING_PAUSED != 'true'
186
+ permissions:
187
+ contents: read
188
+ issues: read
186
189
  runs-on: ${{ inputs.runner }}
187
190
  timeout-minutes: 30
188
191
  steps:
@@ -226,6 +229,8 @@ jobs:
226
229
  - name: Type-Check
227
230
  id: execute
228
231
  if: steps.applicability.outputs.applicable == 'true' && steps.applicability.outputs.affected != 'false'
232
+ env:
233
+ GH_TOKEN: ${{ github.token }}
229
234
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci type_check
230
235
  - name: Retain task receipt
231
236
  if: >-
@@ -243,7 +243,14 @@ jobs:
243
243
  isAbsolute(workspaceRelative) ||
244
244
  workspaceRelative === '..' ||
245
245
  workspaceRelative.startsWith(`..${sep}`)
246
- if (!existsSync('turbo.json') && !existsSync('turbo.jsonc')) {
246
+ // Vite+ repositories are not served by this Turbo-only check.
247
+ const config = resolve(workspace, '.github/code-foundry.yml')
248
+ const optedIntoVitePlus =
249
+ existsSync(config) &&
250
+ /^monorepo_tool:\s*["']?vite-plus["']?\s*(?:#.*)?$/m.test(readFileSync(config, 'utf8'))
251
+ if (optedIntoVitePlus) {
252
+ conservative('monorepo_tool is vite-plus')
253
+ } else if (!existsSync('turbo.json') && !existsSync('turbo.jsonc')) {
247
254
  conservative('Turbo config not found')
248
255
  } else if (outsideWorkspace) {
249
256
  conservative('working-directory is outside the workspace')
@@ -171,7 +171,14 @@ jobs:
171
171
  isAbsolute(workspaceRelative) ||
172
172
  workspaceRelative === '..' ||
173
173
  workspaceRelative.startsWith(`..${sep}`)
174
- if (!existsSync('turbo.json') && !existsSync('turbo.jsonc')) {
174
+ // Vite+ repositories are not served by this Turbo-only check.
175
+ const config = resolve(workspace, '.github/code-foundry.yml')
176
+ const optedIntoVitePlus =
177
+ existsSync(config) &&
178
+ /^monorepo_tool:\s*["']?vite-plus["']?\s*(?:#.*)?$/m.test(readFileSync(config, 'utf8'))
179
+ if (optedIntoVitePlus) {
180
+ conservative('monorepo_tool is vite-plus')
181
+ } else if (!existsSync('turbo.json') && !existsSync('turbo.jsonc')) {
175
182
  conservative('Turbo config not found')
176
183
  } else if (outsideWorkspace) {
177
184
  conservative('working-directory is outside the workspace')
@@ -533,6 +533,9 @@ jobs:
533
533
  type-check:
534
534
  name: Type-Check
535
535
  if: vars.CI_BILLING_PAUSED != 'true'
536
+ permissions:
537
+ contents: read
538
+ issues: read
536
539
  runs-on: ${{ inputs.ci-runner }}
537
540
  timeout-minutes: 30
538
541
  steps:
@@ -576,6 +579,8 @@ jobs:
576
579
  - name: Type-Check
577
580
  id: execute
578
581
  if: steps.applicability.outputs.applicable == 'true' && steps.applicability.outputs.affected != 'false'
582
+ env:
583
+ GH_TOKEN: ${{ github.token }}
579
584
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci type_check
580
585
  - name: Retain task receipt
581
586
  if: >-
@@ -104,6 +104,12 @@ jobs:
104
104
  ci:
105
105
  name: CI
106
106
  if: vars.CI_BILLING_PAUSED != 'true' && (inputs.mode == 'fast' || inputs.mode == 'audit' || inputs.mode == 'release')
107
+ # Forward issue-read only to the CI workflow that runs Type-Check.
108
+ permissions:
109
+ actions: read
110
+ contents: read
111
+ issues: read
112
+ packages: read
107
113
  uses: ./.github/workflows/ci.yml
108
114
  with:
109
115
  runtime-repository: ${{ inputs.runtime-repository }}
@@ -104,6 +104,12 @@ jobs:
104
104
  ci:
105
105
  name: CI
106
106
  if: vars.CI_BILLING_PAUSED != 'true' && (inputs.mode == 'fast' || inputs.mode == 'audit' || inputs.mode == 'release')
107
+ # Type-Check reads the issue-backed requirements ledger with github.token.
108
+ permissions:
109
+ actions: read
110
+ contents: read
111
+ issues: read
112
+ packages: read
107
113
  uses: ./.github/workflows/ci.yml
108
114
  with:
109
115
  runtime-repository: ${{ inputs.runtime-repository }}
@@ -22,6 +22,7 @@ jobs:
22
22
  permissions:
23
23
  actions: read
24
24
  contents: read
25
+ issues: read
25
26
  packages: read
26
27
  security-events: write
27
28
  uses: 0xPlayerOne/code-foundry/.github/workflows/validation.yml@main
@@ -78,6 +78,7 @@ jobs:
78
78
  permissions:
79
79
  actions: read
80
80
  contents: read
81
+ issues: read
81
82
  packages: read
82
83
  security-events: write
83
84
  uses: ./.github/workflows/validation.yml
package/.gitignore CHANGED
@@ -83,3 +83,4 @@ mise.local.lock
83
83
  # committed pre-commit gate is tracked.
84
84
  .githooks/*
85
85
  !.githooks/pre-commit
86
+ .fleet-wt/
package/AGENTS.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  These instructions are the repository-level operating contract for coding agents, including Hermes, OpenCode, and other automation.
6
6
 
7
- They complement `CONTRIBUTING.md`. More specific instructions in nested `AGENTS.md` files and project documentation take precedence for their directory.
7
+ They complement `.github/CONTRIBUTING.md`. More specific instructions in nested `AGENTS.md` files and project documentation take precedence for their directory.
8
8
 
9
9
  <!-- /code-foundry-managed: config-aware-policy -->
10
10
 
@@ -183,7 +183,22 @@ This repository uses the `direct` workflow. Topic pull requests target `main`.
183
183
 
184
184
  ## Validation
185
185
 
186
- Use the shared scripts when present. They detect supported tools and skip inapplicable checks:
186
+ From a consumer repository, use its documented package scripts or the installed
187
+ Code Foundry CLI:
188
+
189
+ ```sh
190
+ npx code-foundry plan --tier fast
191
+ npx code-foundry check --tier fast
192
+ npx code-foundry check --tier audit
193
+ ```
194
+
195
+ `plan` discovers tasks without running them. `check` invokes the shared runtime;
196
+ `fast` includes CI tasks, unit tests and performance, while `audit` adds
197
+ integration, E2E and smoke.
198
+
199
+ From a Code Foundry source checkout only, the shared runtime is also available.
200
+ The paths below are relative to that checkout, not a consumer repository. The
201
+ runtime detects supported tools and skips inapplicable checks:
187
202
 
188
203
  ```sh
189
204
  node src/runtime.mjs ci format
@@ -196,9 +211,10 @@ node src/runtime.mjs ci e2e
196
211
  node src/runtime.mjs ci smoke
197
212
  node src/runtime.mjs ci eval
198
213
  node src/runtime.mjs ci performance
199
- Security and dependency audits run through the GitHub Security workflow.
200
214
  ```
201
215
 
216
+ Security and dependency audits run through the GitHub Security workflow.
217
+
202
218
  Run focused tests first, then the complete applicable set for release, security, workflow, dependency, and configuration changes.
203
219
 
204
220
  At minimum:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.48.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.47.5...v1.48.0) (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * add opt-in Vite+ task runner support ([#763](https://github.com/0xPlayerOne/code-foundry/issues/763)) ([f7970cd](https://github.com/0xPlayerOne/code-foundry/commit/f7970cdf1c1880aaee159d2de79ba043f93711a7))
9
+
10
+ ## [1.47.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.47.4...v1.47.5) (2026-10-15)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * validation-issue-read-v1471 ([#759](https://github.com/0xPlayerOne/code-foundry/issues/759)) ([7119b3e](https://github.com/0xPlayerOne/code-foundry/commit/7119b3e2baebfbfdd47d32428cbf5df2f2079afa))
16
+
17
+ ## [1.47.4](https://github.com/0xPlayerOne/code-foundry/compare/v1.47.3...v1.47.4) (2026-10-15)
18
+
19
+
20
+ ### Documentation
21
+
22
+ * fix generated agent contribution and validation references ([#760](https://github.com/0xPlayerOne/code-foundry/issues/760)) ([c3564c9](https://github.com/0xPlayerOne/code-foundry/commit/c3564c92cb168449317afe2e40359144bf7a0c71))
23
+
24
+ ## [1.47.3](https://github.com/0xPlayerOne/code-foundry/compare/v1.47.2...v1.47.3) (2026-10-09)
25
+
26
+
27
+ ### Maintenance
28
+
29
+ * **fleet:** point the manifest at the rollout worktrees and filter adea's actionlint ([#757](https://github.com/0xPlayerOne/code-foundry/issues/757)) ([55bae8b](https://github.com/0xPlayerOne/code-foundry/commit/55bae8bf99a022bfa03294972d89a3cca20a6203))
30
+
31
+ ## [1.47.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.47.1...v1.47.2) (2026-10-08)
32
+
33
+
34
+ ### Bug Fixes
35
+
36
+ * **qualification:** exclude pending parallel syntax by content, not filename ([#755](https://github.com/0xPlayerOne/code-foundry/issues/755)) ([1f7cae8](https://github.com/0xPlayerOne/code-foundry/commit/1f7cae8167002061850e56e3dc0fa800a69d7be9))
37
+
3
38
  ## [1.47.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.47.0...v1.47.1) (2026-10-08)
4
39
 
5
40
 
@@ -105,44 +140,9 @@
105
140
 
106
141
  * **release:** make the REST fallback draft-aware and idempotent ([#725](https://github.com/0xPlayerOne/code-foundry/issues/725)) ([c29da6e](https://github.com/0xPlayerOne/code-foundry/commit/c29da6efb919d68a568c2328eea2c2f7654334fe))
107
142
 
108
- ## [1.44.8](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.7...v1.44.8) (2026-10-06)
109
-
110
-
111
- ### Bug Fixes
112
-
113
- * **release:** query the version-PR branch directly in the REST fallback ([#722](https://github.com/0xPlayerOne/code-foundry/issues/722)) ([b38f394](https://github.com/0xPlayerOne/code-foundry/commit/b38f394c0ca6438117fdd2f45bd106b0b145a0f9))
114
-
115
- ## [1.44.7](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.6...v1.44.7) (2026-10-05)
116
-
117
-
118
- ### Bug Fixes
119
-
120
- * **release:** complete rejected release-please runs over REST ([#720](https://github.com/0xPlayerOne/code-foundry/issues/720)) ([14e88b5](https://github.com/0xPlayerOne/code-foundry/commit/14e88b5561803fc8d5d3a77b67b99e1be90bc4af))
121
-
122
- ## [1.44.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.5...v1.44.6) (2026-10-05)
123
-
124
-
125
- ### Bug Fixes
126
-
127
- * **config:** pin the lint-bearing CI lane to a standard runner by default ([#718](https://github.com/0xPlayerOne/code-foundry/issues/718)) ([fc0ed14](https://github.com/0xPlayerOne/code-foundry/commit/fc0ed14434151fb11eef7c0d4c324c7a370d5058))
128
-
129
- ## [1.44.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.4...v1.44.5) (2026-10-05)
130
-
131
-
132
- ### Bug Fixes
133
-
134
- * **release:** probe the tag-creation write before trusting the automation token ([#716](https://github.com/0xPlayerOne/code-foundry/issues/716)) ([fabaddc](https://github.com/0xPlayerOne/code-foundry/commit/fabaddcb05d5dc701248b693f91be6ee433f8d39))
135
-
136
- ## [1.44.4](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.3...v1.44.4) (2026-10-03)
137
-
138
-
139
- ### Bug Fixes
140
-
141
- * **ci:** declare the e2e shard inputs on the no-codeql orchestrator ([#714](https://github.com/0xPlayerOne/code-foundry/issues/714)) ([7500208](https://github.com/0xPlayerOne/code-foundry/commit/7500208f3b25dfe55c41ea45b382e021cf804012))
142
-
143
143
  <!-- code-foundry: packaged changelog -->
144
144
 
145
145
  ---
146
146
 
147
- This packaged changelog lists the 20 most recent releases; 243 older releases are omitted to keep the package small.
147
+ This packaged changelog lists the 20 most recent releases; 248 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/CACHING.md CHANGED
@@ -22,6 +22,79 @@ Non-Turborepo repositories do not need either setting. Deployment to Vercel is
22
22
  not required. Custom workflows can pass these values to Turbo directly when
23
23
  the repository's task graph benefits from remote reuse.
24
24
 
25
+ ## Vite+ local task cache (opt-in)
26
+
27
+ Repositories that run their workspace tasks with Vite+ (`vp run`, `vpr`) can
28
+ opt in with `monorepo_tool: vite-plus` in `.github/code-foundry.yml`. Turbo
29
+ stays the default for every repository that does not set it.
30
+
31
+ When opted in, the setup action:
32
+
33
+ - stops treating a `turbo.json` as the active runner, so no Turborepo cache or
34
+ remote-cache step runs;
35
+ - restores `node_modules/.vite/task-cache` as its last step, after Install and
36
+ after every step that archives `node_modules` (Install can replace it);
37
+ - saves it push-only like every other cache, so pull requests and scheduled
38
+ runs cannot poison it;
39
+ - keeps it out of the generic JavaScript build-state archive.
40
+
41
+ Keys are `<namespace>-<workflow>-<job>-<lockfiles>-<vite.config>-<run id>-<attempt>`,
42
+ so every push saves a refreshed entry (more storage than a fixed key). Restore
43
+ keys fall back by lockfiles, job, then any job, but never leave the namespace
44
+ `vite-plus-v1-<OS>-<arch>-node<version>-bun<version>-vp<version>`, because Vite
45
+ Task does not fingerprint the Node or Bun binary. If the `vite-plus` version
46
+ cannot be resolved, nothing is restored or saved.
47
+
48
+ If `VITE_CACHE_PATH` (absolute) is set, Vite+ writes its cache there and the
49
+ setup action caches that directory instead.
50
+
51
+ Vite+ has no remote cache and this repository does not configure one.
52
+
53
+ Known limits (not parity with Turbo):
54
+
55
+ - Vite Task keeps one cache entry per task, so alternating between states
56
+ (A, B, A) re-runs the task instead of hitting the cache.
57
+ - `package.json` scripts are not cached unless the consumer sets
58
+ `run.cache.scripts: true` in its root `vite.config.ts` or passes `--cache` to
59
+ `vp run`. Code Foundry does not change consumer config. Commands that read no
60
+ files, such as a bare `echo`, are never cached.
61
+ - Tasks reading files that only Bun loads may need explicit input globs in the
62
+ consumer's Vite+ config (untested here).
63
+ - `cloudflare-deploy.yml` and `cloudflare-delivery.yml` keep a Turbo-only
64
+ affected check. With `monorepo_tool: vite-plus` they skip it and deploy
65
+ conservatively, even with a leftover `turbo.json`.
66
+
67
+ ### Affected packages
68
+
69
+ `npx code-foundry affected --base <ref> --head <ref>` prints the affected
70
+ packages as JSON (`tool`, `packages`, `removedPackages`, `all`, `conservative`,
71
+ `reason`, `filterArgs`). Both refs must be present in the clone (use a
72
+ non-shallow checkout or fetch the base commit).
73
+
74
+ - Turbo repositories use `turbo query affected --packages`; this module does
75
+ not parse their workspace.
76
+ - Vite+ repositories compute the set from `git diff base...head` and the
77
+ workspace graphs read from the committed trees at both refs, because Vite+
78
+ has no equivalent query. Each changed file marks the package that owns it;
79
+ dependents are added transitively over the union of both graphs' edges, so
80
+ deleted, renamed, moved, and re-wired packages still select their dependents.
81
+ `removedPackages` lists selected packages that no longer exist at the head.
82
+ - Any changed file that no workspace package owns selects every package
83
+ (`all: true`): lockfiles, root manifest, config, scripts, docs, templates. A
84
+ build may read any of them. Only `affected_ignore` in `.github/code-foundry.yml`
85
+ (comma-separated globs, for example `README.md, docs/**`) skips unowned files.
86
+ - Anything the module cannot evaluate faithfully makes the result
87
+ conservative (`all: true`, `conservative: true`): no runner, a git or Turbo
88
+ failure, a malformed or unnamed workspace manifest at either ref, duplicate
89
+ package names, or workspace syntax it does not support (brace, bracket, or
90
+ extglob patterns). Supported syntax is `package.json` `workspaces` (array or
91
+ `{ packages }`) and pnpm `packages:` as a block or flow list, with `*`, `**`,
92
+ `?`, and `!` exclusions.
93
+ - `--tool turbo|vite-plus` overrides `monorepo_tool` for one invocation.
94
+ - For Vite+, `filterArgs` is ready to append to `vp run`. When `all` is true
95
+ run with `-r` instead, and when `packages` is empty skip the run: an empty
96
+ filter list would otherwise select every package.
97
+
25
98
  ## Runner selection
26
99
 
27
100
  Lightweight detection, orchestration, audits, and release metadata jobs can use
@@ -289,6 +289,15 @@ behavior:
289
289
  `TURBO_TEAM`.
290
290
  - `TURBO_TOKEN` and `TURBO_TEAM` provide the Turborepo remote-cache
291
291
  credentials.
292
+ - `monorepo_tool: auto`, `turbo`, or `vite-plus` selects the workspace task
293
+ runner. `auto` (the default) is Turbo when a `turbo.json` or `turbo.jsonc`
294
+ exists and no runner otherwise, exactly as before Vite+ support. Vite+ is
295
+ opt-in: only `monorepo_tool: vite-plus` selects it, and it then takes
296
+ precedence over a turbo config. `doctor` warns when the selected runner has
297
+ no matching evidence in the repository.
298
+ - `cache-vite-plus: auto`, `true`, or `false` is an input of the setup action
299
+ (not a config key). It controls the local Vite+ task cache only when
300
+ `monorepo_tool: vite-plus` is set.
292
301
 
293
302
  Use these controls only after measuring a repeatable benefit. See [Caching and
294
303
  remote caching](CACHING.md).
package/docs/README.md CHANGED
@@ -33,7 +33,7 @@ details before copying any guide elsewhere.
33
33
  - [Declarative fleet rollouts](fleet-rollouts.md) — inventory, canaries, staged upgrades, and recovery.
34
34
  - [Fleet release eligibility](fleet-release-eligibility.md) — require verified release evidence before upgrades.
35
35
  - [Verified Cloudflare delivery](cloudflare-delivery.md) — build, verify, canary, and promote Worker versions.
36
- - [Caching and remote caching](CACHING.md) — configure package, build, and Turborepo caching.
36
+ - [Caching and remote caching](CACHING.md) — configure package, build, Turborepo, and opt-in Vite+ caching.
37
37
 
38
38
  ## Repository-specific documentation
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.47.1",
3
+ "version": "1.48.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": {
package/src/cli.mjs CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
 
8
8
  const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
9
9
 
10
- /** @typedef {{ target: string, root: string, source: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, exclude: string[], base: string, head: string, tag: string, workflow: string, mode: string, version: string, versionExplicit: boolean, repository: string, expectedSha: string, assets: string[], rootProvided: boolean, releaseSubcommand?: string, fleetSubcommand?: string, integritySubcommand?: 'settings'|'release'|'manifest', ciSubcommand?: 'pause'|'resume'|'status' }} Options */
10
+ /** @typedef {{ target: string, root: string, source: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, exclude: string[], base: string, head: string, tag: string, workflow: string, mode: string, version: string, versionExplicit: boolean, repository: string, expectedSha: string, assets: string[], rootProvided: boolean, tool: string, headProvided: boolean, releaseSubcommand?: string, fleetSubcommand?: string, integritySubcommand?: 'settings'|'release'|'manifest', ciSubcommand?: 'pause'|'resume'|'status' }} Options */
11
11
  /** @typedef {{ command: string, options: Options }} ParsedArgs */
12
12
 
13
13
  const usage = `code-foundry — initialize and maintain agent-ready repositories
@@ -18,6 +18,7 @@ Usage:
18
18
  npx code-foundry doctor [--target PATH]
19
19
  npx code-foundry plan [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--json]
20
20
  npx code-foundry check [--target PATH] [--tier fast|audit] [--changed] [--base REF] [--timeout SECONDS] [--json]
21
+ npx code-foundry affected [--target PATH] [--base REF] [--head REF] [--tool auto|turbo|vite-plus]
21
22
  npx code-foundry ci pause|resume|status [--target PATH]
22
23
  npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
23
24
  npx code-foundry release hook --tag TAG --workflow WORKFLOW
@@ -37,7 +38,8 @@ Options:
37
38
  --force Replace protected standard documents
38
39
  --github Apply a verified fast-forward through GitHub
39
40
  --base BRANCH Release source branch (default: main)
40
- --head BRANCH Branch to reconcile (default: staging)
41
+ --head BRANCH Branch to reconcile (default: staging; HEAD for affected)
42
+ --tool TOOL Task runner for affected: auto, turbo, or vite-plus (default: auto)
41
43
  --tag TAG Published release tag for a post-release hook
42
44
  --workflow FILE Workflow to dispatch for a post-release hook
43
45
  --mode MODE auto, workflow-dispatch, release-event, or disabled
@@ -78,6 +80,8 @@ function parseArgs(argv) {
78
80
  tag: '',
79
81
  workflow: '',
80
82
  mode: 'auto',
83
+ tool: 'auto',
84
+ headProvided: false,
81
85
  version: `v${readPackageVersion(packageRoot)}`,
82
86
  versionExplicit: false,
83
87
  repository: '',
@@ -128,8 +132,15 @@ function parseArgs(argv) {
128
132
  else if (arg === '--force') options.force = true
129
133
  else if (arg === '--github') options.github = true
130
134
  else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
131
- else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
132
- else if (arg === '--tag') options.tag = argv.shift() ?? fail('--tag requires a tag')
135
+ else if (arg === '--head') {
136
+ options.head = argv.shift() ?? fail('--head requires a branch')
137
+ options.headProvided = true
138
+ } else if (arg === '--tool') {
139
+ if (command !== 'affected') fail('--tool is only supported by the affected command')
140
+ options.tool = argv.shift() ?? fail('--tool requires auto, turbo, or vite-plus')
141
+ if (!['auto', 'turbo', 'vite-plus'].includes(options.tool))
142
+ fail(`unsupported --tool: ${options.tool}; use auto, turbo, or vite-plus`)
143
+ } else if (arg === '--tag') options.tag = argv.shift() ?? fail('--tag requires a tag')
133
144
  else if (arg === '--repo') {
134
145
  if (command !== 'release-integrity')
135
146
  fail('--repo is only supported by release-integrity commands')
@@ -204,6 +215,18 @@ async function main() {
204
215
  console.error(`code-foundry: ${error instanceof Error ? error.message : String(error)}`)
205
216
  process.exitCode = 1
206
217
  }
218
+ } else if (command === 'affected') {
219
+ try {
220
+ const { affectedPackages } = await import('./lib/task-filters.mjs')
221
+ const result = affectedPackages(target, {
222
+ base: options.base,
223
+ head: options.headProvided ? options.head : 'HEAD',
224
+ tool: options.tool,
225
+ })
226
+ console.log(JSON.stringify(result, null, 2))
227
+ } catch (error) {
228
+ fail(error instanceof Error ? error.message : String(error))
229
+ }
207
230
  } else if (command === 'release') {
208
231
  try {
209
232
  const {
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
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
- import { recommendRunners, resolveProfile } from '../lib/profile.mjs'
7
+ import { recommendRunners, resolveMonorepoTool, resolveProfile } from '../lib/profile.mjs'
8
8
  import { packageManagerForLockfile } from '../lib/lockfiles.mjs'
9
9
  import { doctorGithub } from '../lib/github-doctor.mjs'
10
10
  import { isGeneratedEventCaller } from './sync.mjs'
@@ -38,6 +38,10 @@ export function doctor(root, options = {}) {
38
38
  : 'Toolchain: native (mise not configured).'
39
39
  )
40
40
  }
41
+ const monorepoTool = resolveMonorepoTool(target, config)
42
+ for (const message of monorepoTool.warnings) warn(message)
43
+ if (monorepoTool.tool === 'vite-plus')
44
+ console.log(`Task runner: vite-plus (${monorepoTool.reason}).`)
41
45
  const hooks = git(target, ['config', '--get', 'core.hooksPath'])
42
46
  if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
43
47
  // A correctly enabled hook that enforces nothing is the failure this check
@@ -2168,6 +2168,7 @@ function createDefaultConfig(root, source, configuredWorkflow) {
2168
2168
  coverage_metrics: 'lines',
2169
2169
  coverage_report: '',
2170
2170
  turbo_remote: 'auto',
2171
+ monorepo_tool: 'auto',
2171
2172
  release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
2172
2173
  npm_publish: 'false',
2173
2174
  post_release: 'false',
@@ -157,17 +157,16 @@ function lintContracts(consumer, candidate, destination, actionlint, version) {
157
157
  paths.push(target)
158
158
  }
159
159
  // Check all candidate callees as well as rendered callers, without executing them.
160
- // actionlint v1.7.12 (still the latest release as of 2026-10-07) predates
160
+ // actionlint v1.7.12 (still the latest release as of 2026-10-08) predates
161
161
  // the June 2026 step parallelism keywords (background/wait/wait-all/
162
162
  // cancel/parallel), so files using them are excluded from this pass until
163
163
  // actionlint ships support — GitHub's own loader accepts the syntax, which
164
164
  // is verified by live runs and pinned in test/billing-lanes.test.mjs.
165
- // Restore the removal of this exclusion when rhysd/actionlint releases
166
- // post-parallel support.
167
- const actionlintPendingParallelSyntax = new Set(['validation-billing.yml'])
168
- const workflows = readdirSync(join(destination, '.github/workflows')).filter(
169
- (name) => /\.ya?ml$/.test(name) && !actionlintPendingParallelSyntax.has(name)
170
- )
165
+ // The exclusion is content-based so repository-owned workflows adopting the
166
+ // keywords (adea's desktop-shell, for one) stay covered without list
167
+ // maintenance. Restore the removal of this exclusion when
168
+ // rhysd/actionlint releases post-parallel support.
169
+ const workflows = actionlintEligibleWorkflows(join(destination, '.github/workflows'))
171
170
  run(
172
171
  actionlint,
173
172
  [
@@ -180,6 +179,15 @@ function lintContracts(consumer, candidate, destination, actionlint, version) {
180
179
  return paths.length
181
180
  }
182
181
 
182
+ /** @param {string} workflowsDirectory */
183
+ export function actionlintEligibleWorkflows(workflowsDirectory) {
184
+ const pendingParallelSyntax = /^[ \t]*(?:-[ \t]+)?(?:background|wait|wait-all|cancel|parallel):/m
185
+ return readdirSync(workflowsDirectory).filter((name) => {
186
+ if (!/\.ya?ml$/.test(name)) return false
187
+ return !pendingParallelSyntax.test(readFileSync(join(workflowsDirectory, name), 'utf8'))
188
+ })
189
+ }
190
+
183
191
  /** @param {{ packageFile: string, sourceSha: string, reportPath: string, actionlint?: string }} options */
184
192
  export function qualifyCandidate(options) {
185
193
  if (!/^[a-f0-9]{40}$/.test(options.sourceSha))
@@ -110,6 +110,7 @@ on:
110
110
  permissions:
111
111
  actions: read
112
112
  contents: read
113
+ issues: read
113
114
  packages: read
114
115
  security-events: write
115
116
 
@@ -160,3 +160,123 @@ function detectReleaseType(root) {
160
160
  }
161
161
 
162
162
  export { includesValue }
163
+
164
+ /**
165
+ * Workspace task runner. `auto` (default) is Turbo when a turbo config exists,
166
+ * else none; Vite+ needs an explicit `monorepo_tool: vite-plus`.
167
+ */
168
+ export const MONOREPO_TOOLS = ['auto', 'turbo', 'vite-plus']
169
+
170
+ /** @typedef {'turbo'|'vite-plus'|'none'} ResolvedTool */
171
+ /** @typedef {{ configured: string, tool: ResolvedTool, reason: string, warnings: string[] }} ToolResolution */
172
+
173
+ /** @param {string} root @returns {Record<string, any> | null} */
174
+ function readPackageJson(root) {
175
+ try {
176
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
177
+ } catch {
178
+ return null
179
+ }
180
+ }
181
+
182
+ /** @param {string} root */
183
+ export function hasTurboConfig(root) {
184
+ return existsSync(join(root, 'turbo.json')) || existsSync(join(root, 'turbo.jsonc'))
185
+ }
186
+
187
+ /**
188
+ * Whether a shell command invokes the Vite+ task runner. `vp run ...` and the
189
+ * `vpr` shorthand populate the task cache; plain `vp build` or `vp test` do
190
+ * not, so they are intentionally not matched.
191
+ * @param {string} command
192
+ */
193
+ export function commandUsesVitePlus(command) {
194
+ return /(?:^|[\s;&|(])(?:vp\s+(?:-C\s+\S+\s+)?run|vpr)(?:\s|$)/.test(command)
195
+ }
196
+
197
+ /** @param {string} command */
198
+ export function commandUsesTurbo(command) {
199
+ return /\bturbo(?:\s|$)/.test(command)
200
+ }
201
+
202
+ /**
203
+ * Evidence that a repository declares Vite+: the `vite-plus` package in any
204
+ * dependency table or a script that runs `vp run` / `vpr`.
205
+ * @param {string} root
206
+ * @returns {{ declared: boolean, via: string[] }}
207
+ */
208
+ export function detectVitePlus(root) {
209
+ const packageJson = readPackageJson(root)
210
+ /** @type {string[]} */
211
+ const via = []
212
+ if (!packageJson) return { declared: false, via }
213
+ for (const table of ['dependencies', 'devDependencies', 'optionalDependencies']) {
214
+ if (
215
+ packageJson[table] &&
216
+ typeof packageJson[table] === 'object' &&
217
+ packageJson[table]['vite-plus']
218
+ )
219
+ via.push(`${table}.vite-plus`)
220
+ }
221
+ const scripts =
222
+ packageJson.scripts && typeof packageJson.scripts === 'object' ? packageJson.scripts : {}
223
+ for (const [name, command] of Object.entries(scripts)) {
224
+ if (typeof command === 'string' && commandUsesVitePlus(command)) via.push(`scripts.${name}`)
225
+ }
226
+ return { declared: via.length > 0, via }
227
+ }
228
+
229
+ /**
230
+ * Resolve which task runner a repository uses. Never throws: invalid or
231
+ * unsatisfied configuration falls back to the legacy behavior and is reported
232
+ * in `warnings` so doctor can surface it.
233
+ * @param {string} root
234
+ * @param {Record<string, string>} [config] Defaults to .github/code-foundry.yml.
235
+ * @returns {ToolResolution}
236
+ */
237
+ export function resolveMonorepoTool(
238
+ root,
239
+ config = readConfig(join(root, '.github/code-foundry.yml'))
240
+ ) {
241
+ /** @type {string[]} */
242
+ const warnings = []
243
+ let selected = (config.monorepo_tool ?? '').trim() || 'auto'
244
+ if (!MONOREPO_TOOLS.includes(selected)) {
245
+ warnings.push(
246
+ `unsupported monorepo_tool: ${selected}; use auto, turbo, or vite-plus. Falling back to auto.`
247
+ )
248
+ selected = 'auto'
249
+ }
250
+ const turbo = hasTurboConfig(root)
251
+
252
+ if (selected === 'vite-plus') {
253
+ const evidence = detectVitePlus(root)
254
+ if (!evidence.declared)
255
+ warnings.push(
256
+ 'monorepo_tool: vite-plus but neither a vite-plus dependency nor a `vp run` script was found in package.json.'
257
+ )
258
+ return {
259
+ configured: selected,
260
+ tool: 'vite-plus',
261
+ reason: turbo
262
+ ? 'monorepo_tool: vite-plus (turbo config present but ignored)'
263
+ : 'monorepo_tool: vite-plus',
264
+ warnings,
265
+ }
266
+ }
267
+ if (selected === 'turbo') {
268
+ if (!turbo) warnings.push('monorepo_tool: turbo but no turbo.json or turbo.jsonc was found.')
269
+ return {
270
+ configured: selected,
271
+ tool: turbo ? 'turbo' : 'none',
272
+ reason: 'monorepo_tool: turbo',
273
+ warnings,
274
+ }
275
+ }
276
+ return {
277
+ configured: selected,
278
+ tool: turbo ? 'turbo' : 'none',
279
+ reason: turbo ? 'turbo config detected' : 'no task runner configured',
280
+ warnings,
281
+ }
282
+ }
@@ -1,6 +1,10 @@
1
1
  // @ts-check
2
2
 
3
3
  import { execFileSync } from 'node:child_process'
4
+ import { existsSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { listValue, readConfig } from './config.mjs'
7
+ import { resolveMonorepoTool } from './profile.mjs'
4
8
 
5
9
  /**
6
10
  * Tasks that support `filter_<task>` path filters in code-foundry.yml. A
@@ -151,3 +155,379 @@ export function taskAffected(task, filters, paths) {
151
155
  const matchers = globs.map(globToRegExp)
152
156
  return paths.some((path) => matchers.some((matcher) => matcher.test(path)))
153
157
  }
158
+
159
+ /**
160
+ * @typedef {{ name: string, dir: string, dependencies: string[] }} WorkspacePackage
161
+ * `dir` is repo-relative with forward slashes; the workspace root is ''.
162
+ */
163
+ /** @typedef {{ label: string, files: string[], read: (path: string) => string }} WorkspaceSource */
164
+ /**
165
+ * @typedef {{
166
+ * tool: 'turbo'|'vite-plus'|'none',
167
+ * base: string,
168
+ * head: string,
169
+ * all: boolean,
170
+ * conservative: boolean,
171
+ * reason: string,
172
+ * mergeBase?: string,
173
+ * changedFiles: number,
174
+ * packages: string[],
175
+ * removedPackages: string[],
176
+ * filterArgs: string[],
177
+ * }} AffectedResult
178
+ */
179
+
180
+ const SAFE_REF = /^[A-Za-z0-9._/~^@{}-]+$/
181
+ const SAFE_PACKAGE_NAME = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i
182
+ /** Workspace glob syntax this module cannot evaluate faithfully. */
183
+ const UNSUPPORTED_GLOB = /[{}[\]()+@\\]|^!!|\*\*[^/]|[^/]\*\*/
184
+
185
+ /** @param {string} ref @param {string} label */
186
+ function assertRef(ref, label) {
187
+ if (!ref || ref.startsWith('-') || !SAFE_REF.test(ref))
188
+ throw new Error(`invalid ${label} ref: ${ref}`)
189
+ }
190
+
191
+ /** @param {string} root @param {string[]} args */
192
+ const git = (root, args) =>
193
+ execFileSync('git', args, {
194
+ cwd: root,
195
+ encoding: 'utf8',
196
+ stdio: ['ignore', 'pipe', 'pipe'],
197
+ maxBuffer: 256 * 1024 * 1024,
198
+ })
199
+
200
+ /**
201
+ * Workspace files as of a git ref (committed objects, not the working tree).
202
+ * @param {string} root
203
+ * @param {string} ref
204
+ * @returns {WorkspaceSource}
205
+ */
206
+ export function gitWorkspaceSource(root, ref) {
207
+ assertRef(ref, 'workspace')
208
+ const files = git(root, ['ls-tree', '-r', '-z', '--name-only', ref]).split('\0').filter(Boolean)
209
+ return { label: ref, files, read: (path) => git(root, ['show', `${ref}:${path}`]) }
210
+ }
211
+
212
+ /**
213
+ * Workspace globs from package.json `workspaces` and pnpm-workspace.yaml.
214
+ * Throws on syntax it cannot evaluate so callers fail closed.
215
+ * @param {WorkspaceSource} source
216
+ * @returns {string[]}
217
+ */
218
+ export function workspaceGlobs(source) {
219
+ /** @type {string[]} */
220
+ const globs = []
221
+ if (source.files.includes('package.json')) {
222
+ let manifest
223
+ try {
224
+ manifest = JSON.parse(source.read('package.json'))
225
+ } catch {
226
+ throw new Error(`malformed package.json at ${source.label}`)
227
+ }
228
+ const workspaces = manifest.workspaces
229
+ const list = Array.isArray(workspaces) ? workspaces : workspaces?.packages
230
+ if (workspaces !== undefined && !Array.isArray(list))
231
+ throw new Error('unsupported workspaces declaration in package.json')
232
+ if (Array.isArray(list)) {
233
+ for (const item of list) {
234
+ if (typeof item !== 'string') throw new Error('non-string workspace glob in package.json')
235
+ globs.push(item)
236
+ }
237
+ }
238
+ }
239
+ if (source.files.includes('pnpm-workspace.yaml')) {
240
+ let inPackages = false
241
+ for (const line of source.read('pnpm-workspace.yaml').split(/\r?\n/)) {
242
+ if (/^packages:\s*$/.test(line)) inPackages = true
243
+ else if (/^packages:\s*\S/.test(line)) {
244
+ // Flow style: packages: ['apps/*', 'packages/*']
245
+ const flow = line.match(/^packages:\s*\[(.*)\]\s*(?:#.*)?$/)
246
+ if (!flow) throw new Error('unsupported packages declaration in pnpm-workspace.yaml')
247
+ for (const part of flow[1].split(',')) {
248
+ const entry = part.trim()
249
+ if (!entry) continue
250
+ const item = entry.match(/^(['"]?)([^'"]+)\1$/)
251
+ if (!item) throw new Error(`unsupported pnpm-workspace.yaml entry: ${entry}`)
252
+ globs.push(item[2])
253
+ }
254
+ } else if (/^\S/.test(line)) inPackages = false
255
+ else if (inPackages) {
256
+ if (/^\s*(?:#.*)?$/.test(line)) continue
257
+ const item = line.match(/^\s*-\s*(['"]?)([^'"#]+?)\1\s*(?:#.*)?$/)
258
+ if (!item) throw new Error(`unsupported pnpm-workspace.yaml entry: ${line.trim()}`)
259
+ globs.push(item[2])
260
+ }
261
+ }
262
+ }
263
+ for (const glob of globs) {
264
+ const body = glob.startsWith('!') ? glob.slice(1) : glob
265
+ if (!body || UNSUPPORTED_GLOB.test(body)) throw new Error(`unsupported workspace glob: ${glob}`)
266
+ }
267
+ return globs
268
+ }
269
+
270
+ /**
271
+ * Workspace packages including the root. Throws when a member cannot be read or named.
272
+ * @param {WorkspaceSource} source
273
+ * @returns {WorkspacePackage[]}
274
+ */
275
+ export function listWorkspacePackages(source) {
276
+ const globs = workspaceGlobs(source)
277
+ const includes = globs.filter((glob) => !glob.startsWith('!')).map(globToRegExp)
278
+ const excludes = globs
279
+ .filter((glob) => glob.startsWith('!'))
280
+ .map((glob) => globToRegExp(glob.slice(1)))
281
+ const directories = source.files
282
+ .filter((file) => file.endsWith('/package.json') && !file.split('/').includes('node_modules'))
283
+ .map((file) => file.slice(0, -'/package.json'.length))
284
+ .filter((dir) => includes.some((re) => re.test(dir)) && !excludes.some((re) => re.test(dir)))
285
+
286
+ /** @type {Array<{ name: string, dir: string, manifest: Record<string, any> }>} */
287
+ const manifests = []
288
+ /** @param {string} dir @param {boolean} isRoot */
289
+ const load = (dir, isRoot) => {
290
+ const path = dir ? `${dir}/package.json` : 'package.json'
291
+ let manifest
292
+ try {
293
+ manifest = JSON.parse(source.read(path))
294
+ } catch {
295
+ throw new Error(`malformed manifest ${path} at ${source.label}`)
296
+ }
297
+ if (typeof manifest.name === 'string' && manifest.name) {
298
+ manifests.push({ name: manifest.name, dir, manifest })
299
+ } else if (isRoot) {
300
+ manifests.push({ name: '//', dir, manifest })
301
+ } else {
302
+ throw new Error(`workspace package ${path} has no name`)
303
+ }
304
+ }
305
+ if (source.files.includes('package.json')) load('', true)
306
+ for (const dir of directories) load(dir, false)
307
+
308
+ const seen = new Set()
309
+ for (const item of manifests) {
310
+ if (seen.has(item.name)) throw new Error(`duplicate workspace package name: ${item.name}`)
311
+ seen.add(item.name)
312
+ }
313
+ return manifests.map(({ name, dir, manifest }) => ({
314
+ name,
315
+ dir,
316
+ dependencies: [
317
+ ...new Set(
318
+ ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap(
319
+ (table) =>
320
+ Object.keys(manifest[table] ?? {}).filter((dep) => seen.has(dep) && dep !== name)
321
+ )
322
+ ),
323
+ ],
324
+ }))
325
+ }
326
+
327
+ /**
328
+ * Changed files to affected packages across base and head graphs, so deleted,
329
+ * renamed, and re-wired packages keep their dependents. An unowned file selects
330
+ * every package unless `ignore` globs (`affected_ignore`) say otherwise.
331
+ * @param {WorkspacePackage[][]} graphs Head graph first.
332
+ * @param {string[]} changedFiles Repo-relative, forward slashes.
333
+ * @param {string[]} [ignore] Repo-relative globs of unowned files to skip.
334
+ * @returns {{ all: boolean, packages: string[], reason: string }}
335
+ */
336
+ export function affectedFromChangedFiles(graphs, changedFiles, ignore = []) {
337
+ const ignored = ignore.map(globToRegExp)
338
+ const everything = [...new Set(graphs.flat().map((item) => item.name))].toSorted()
339
+ const direct = new Set()
340
+ for (const file of changedFiles) {
341
+ const owners = new Set()
342
+ for (const graph of graphs) {
343
+ const owner = graph
344
+ .filter((item) => item.dir !== '' && file.startsWith(`${item.dir}/`))
345
+ .toSorted((a, b) => b.dir.length - a.dir.length)[0]
346
+ if (owner) owners.add(owner.name)
347
+ }
348
+ if (owners.size > 0) {
349
+ for (const name of owners) direct.add(name)
350
+ } else if (!ignored.some((re) => re.test(file))) {
351
+ return { all: true, packages: everything, reason: `shared or unowned file changed: ${file}` }
352
+ }
353
+ }
354
+
355
+ const dependents = new Map()
356
+ for (const item of graphs.flat())
357
+ for (const dep of item.dependencies) {
358
+ if (!dependents.has(dep)) dependents.set(dep, new Set())
359
+ dependents.get(dep).add(item.name)
360
+ }
361
+ const affected = new Set(direct)
362
+ const queue = [...direct]
363
+ while (queue.length) {
364
+ for (const next of dependents.get(queue.pop()) ?? []) {
365
+ if (!affected.has(next)) {
366
+ affected.add(next)
367
+ queue.push(next)
368
+ }
369
+ }
370
+ }
371
+ return { all: false, packages: [...affected].toSorted(), reason: 'git diff and workspace graphs' }
372
+ }
373
+
374
+ /**
375
+ * `vp run` arguments selecting the given packages (the root is `-w`).
376
+ * @param {WorkspacePackage[]} packages
377
+ * @param {string[]} names
378
+ */
379
+ export function vitePlusFilterArgs(packages, names) {
380
+ /** @type {string[]} */
381
+ const args = []
382
+ for (const name of names) {
383
+ const item = packages.find((candidate) => candidate.name === name)
384
+ if (item?.dir === '') args.push('-w')
385
+ else if (SAFE_PACKAGE_NAME.test(name)) args.push('--filter', name)
386
+ else throw new Error(`unsafe package name for --filter: ${name}`)
387
+ }
388
+ return args
389
+ }
390
+
391
+ /**
392
+ * @param {string} root @param {string} base @param {string} head
393
+ * @returns {string[]}
394
+ */
395
+ function gitChangedFiles(root, base, head) {
396
+ assertRef(base, 'base')
397
+ assertRef(head, 'head')
398
+ const output = git(root, ['diff', '--name-only', '--no-renames', '-z', `${base}...${head}`, '--'])
399
+ return output.split('\0').filter(Boolean)
400
+ }
401
+
402
+ /**
403
+ * @param {string} root @param {string} base @param {string} head
404
+ * @param {(command: string, args: string[], cwd: string) => string} run
405
+ * @returns {string[]} affected package names
406
+ */
407
+ function turboAffected(root, base, head, run) {
408
+ assertRef(base, 'base')
409
+ assertRef(head, 'head')
410
+ const local = join(root, 'node_modules/.bin/turbo')
411
+ const output = run(
412
+ existsSync(local) ? local : 'turbo',
413
+ ['query', 'affected', '--packages', `--base=${base}`, `--head=${head}`, '--no-update-notifier'],
414
+ root
415
+ )
416
+ const result = JSON.parse(output)
417
+ const items = result?.data?.affectedPackages?.items ?? result?.data?.affectedTasks?.items
418
+ if (!Array.isArray(items)) throw new Error('Turbo affected query returned no package/task items')
419
+ return [
420
+ ...new Set(
421
+ items
422
+ .map(
423
+ (/** @type {any} */ item) =>
424
+ item?.name ?? item?.package?.name ?? String(item?.fullName ?? '').split('#')[0]
425
+ )
426
+ .filter(Boolean)
427
+ ),
428
+ ].toSorted()
429
+ }
430
+
431
+ /** @type {(command: string, args: string[], cwd: string) => string} */
432
+ const defaultRun = (command, args, cwd) =>
433
+ execFileSync(command, args, {
434
+ cwd,
435
+ encoding: 'utf8',
436
+ stdio: ['ignore', 'pipe', 'pipe'],
437
+ maxBuffer: 64 * 1024 * 1024,
438
+ })
439
+
440
+ /** @param {unknown} error */
441
+ const describe = (error) => (error instanceof Error ? error.message.split('\n')[0] : String(error))
442
+
443
+ /**
444
+ * Packages affected between two refs. Turbo uses `turbo query affected`; Vite+
445
+ * has no such query, so git and the workspace graphs at both refs decide.
446
+ * Anything unprovable is conservative (`all: true`).
447
+ * @param {string} root
448
+ * @param {{ base: string, head: string, tool?: string, run?: (command: string, args: string[], cwd: string) => string }} options
449
+ * @returns {AffectedResult}
450
+ */
451
+ export function affectedPackages(root, options) {
452
+ const config = readConfig(join(root, '.github/code-foundry.yml'))
453
+ const requested = options.tool && options.tool !== 'auto' ? options.tool : undefined
454
+ const resolution = resolveMonorepoTool(
455
+ root,
456
+ requested ? { ...config, monorepo_tool: requested } : config
457
+ )
458
+ const { base, head } = options
459
+
460
+ /** @param {Partial<AffectedResult> & { reason: string }} partial @returns {AffectedResult} */
461
+ const result = (partial) => ({
462
+ tool: resolution.tool,
463
+ base,
464
+ head,
465
+ all: false,
466
+ conservative: false,
467
+ changedFiles: 0,
468
+ packages: [],
469
+ removedPackages: [],
470
+ filterArgs: [],
471
+ ...partial,
472
+ })
473
+ /** @param {string} reason @param {string[]} [names] */
474
+ const conservative = (reason, names = []) =>
475
+ result({ all: true, conservative: true, reason, packages: names })
476
+
477
+ if (resolution.tool === 'none')
478
+ return conservative(
479
+ `no task runner configured (${resolution.reason}); treating all packages as affected`
480
+ )
481
+
482
+ if (resolution.tool === 'turbo') {
483
+ // Turbo owns its own graph; this module's workspace parsing is never a
484
+ // prerequisite for the Turbo path.
485
+ try {
486
+ return result({
487
+ reason: 'turbo query affected',
488
+ packages: turboAffected(root, base, head, options.run ?? defaultRun),
489
+ })
490
+ } catch (error) {
491
+ return conservative(
492
+ `affected selection failed (${describe(error)}); treating all packages as affected`
493
+ )
494
+ }
495
+ }
496
+
497
+ /** @type {string[]} */
498
+ let headNames = []
499
+ try {
500
+ assertRef(base, 'base')
501
+ assertRef(head, 'head')
502
+ const headPackages = listWorkspacePackages(gitWorkspaceSource(root, head))
503
+ headNames = headPackages.map((item) => item.name).toSorted()
504
+ // One merge-base serves both the historical graph and the diff, so
505
+ // divergent branches are compared against their real common ancestor.
506
+ const mergeBase = git(root, ['merge-base', base, head]).trim()
507
+ const basePackages = listWorkspacePackages(gitWorkspaceSource(root, mergeBase))
508
+ const files = gitChangedFiles(root, mergeBase, head)
509
+ const selection = affectedFromChangedFiles(
510
+ [headPackages, basePackages],
511
+ files,
512
+ listValue(config.affected_ignore ?? '')
513
+ )
514
+ const existing = selection.packages.filter((name) => headNames.includes(name))
515
+ const removed = selection.packages.filter((name) => !headNames.includes(name))
516
+ return result({
517
+ mergeBase,
518
+ reason: selection.reason,
519
+ changedFiles: files.length,
520
+ all: selection.all,
521
+ packages: selection.all ? headNames : existing,
522
+ removedPackages: selection.all ? [] : removed,
523
+ // An empty selection must stay empty: callers skip the run instead of
524
+ // passing no filters, which would run every package.
525
+ filterArgs: selection.all ? [] : vitePlusFilterArgs(headPackages, existing),
526
+ })
527
+ } catch (error) {
528
+ return conservative(
529
+ `affected selection failed (${describe(error)}); treating all packages as affected`,
530
+ headNames
531
+ )
532
+ }
533
+ }
@@ -22,6 +22,7 @@ jobs:
22
22
  permissions:
23
23
  actions: read
24
24
  contents: read
25
+ issues: read
25
26
  packages: read
26
27
  security-events: write
27
28
  uses: ./.github/workflows/validation.yml