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.
- package/.github/CONTRIBUTING.md +12 -6
- package/.github/actions/setup/action.yml +105 -7
- package/.github/workflows/ci.yml +5 -0
- package/.github/workflows/cloudflare-delivery.yml +8 -1
- package/.github/workflows/cloudflare-deploy.yml +8 -1
- package/.github/workflows/validation-billing.yml +5 -0
- package/.github/workflows/validation-no-codeql.yml +6 -0
- package/.github/workflows/validation.yml +6 -0
- package/.github/workflows/validation_audit_self-ci.yml +1 -0
- package/.github/workflows/validation_self-ci.yml +1 -0
- package/.gitignore +1 -0
- package/AGENTS.md +19 -3
- package/CHANGELOG.md +36 -36
- package/docs/CACHING.md +73 -0
- package/docs/CONFIGURATION.md +9 -0
- package/docs/README.md +1 -1
- package/package.json +1 -1
- package/src/cli.mjs +27 -4
- package/src/commands/doctor.mjs +5 -1
- package/src/commands/sync.mjs +1 -0
- package/src/lib/consumer-qualification.mjs +15 -7
- package/src/lib/merge-queue.mjs +1 -0
- package/src/lib/profile.mjs +120 -0
- package/src/lib/task-filters.mjs +380 -0
- package/src/templates/workflows/validation-audit.yml +1 -0
package/.github/CONTRIBUTING.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
+
script_uses_runner() {
|
|
126
130
|
[ -f package.json ] || return 1
|
|
127
|
-
|
|
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.
|
|
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
|
|
145
|
-
|
|
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 [
|
|
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" ] ||
|
|
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 }}-
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 }}
|
package/.gitignore
CHANGED
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
|
|
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
|
-
|
|
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;
|
|
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
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
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.
|
|
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')
|
|
132
|
-
|
|
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 {
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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-
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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))
|
package/src/lib/merge-queue.mjs
CHANGED
package/src/lib/profile.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/task-filters.mjs
CHANGED
|
@@ -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
|
+
}
|