code-foundry 1.2.0 → 1.3.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.
@@ -39,24 +39,46 @@ export function manageCiBilling(root, action) {
39
39
  if (action === 'pause') {
40
40
  const rulesets = activeBranchRulesets(repository)
41
41
  const candidates = rulesets.filter((ruleset) => requiredChecks(ruleset).some(isManagedCheck))
42
- const inherited = candidates.filter((ruleset) => ruleset.source_type !== 'Repository' || ruleset.source !== repository)
42
+ const inherited = candidates.filter(
43
+ (ruleset) => ruleset.source_type !== 'Repository' || ruleset.source !== repository
44
+ )
43
45
  if (inherited.length) {
44
- throw new Error(`${CI_BILLING_REQUIRED_CHECK} is enforced by an inherited ruleset that this repository cannot safely edit.`)
46
+ throw new Error(
47
+ `${CI_BILLING_REQUIRED_CHECK} is enforced by an inherited ruleset that this repository cannot safely edit.`
48
+ )
45
49
  }
46
50
  if (!candidates.length) {
47
51
  if (paused && backup) {
48
52
  const cancelledRuns = cancelActiveRuns(repository)
49
- const result = { repository, paused: true, changed: false, cancelledRuns, rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName) }
53
+ const result = {
54
+ repository,
55
+ paused: true,
56
+ changed: false,
57
+ cancelledRuns,
58
+ rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName),
59
+ }
50
60
  console.log(JSON.stringify(result, null, 2))
51
61
  return result
52
62
  }
53
- throw new Error(`No active branch ruleset requires ${CI_BILLING_REQUIRED_CHECK}; refusing to pause without a restorable managed gate.`)
63
+ throw new Error(
64
+ `No active branch ruleset requires ${CI_BILLING_REQUIRED_CHECK}; refusing to pause without a restorable managed gate.`
65
+ )
54
66
  }
55
67
  if (backup && !paused) {
56
- throw new Error(`${CI_BILLING_BACKUP_VARIABLE} already exists while CI is active; inspect or remove the stale backup before pausing.`)
68
+ throw new Error(
69
+ `${CI_BILLING_BACKUP_VARIABLE} already exists while CI is active; inspect or remove the stale backup before pausing.`
70
+ )
57
71
  }
58
- if (backup && candidates.some((ruleset) => !backup.rulesets.some((/** @type {any} */ saved) => saved.rulesetId === ruleset.id))) {
59
- throw new Error(`${CI_BILLING_BACKUP_VARIABLE} does not cover every managed ruleset; refusing to overwrite the recovery state.`)
72
+ if (
73
+ backup &&
74
+ candidates.some(
75
+ (ruleset) =>
76
+ !backup.rulesets.some((/** @type {any} */ saved) => saved.rulesetId === ruleset.id)
77
+ )
78
+ ) {
79
+ throw new Error(
80
+ `${CI_BILLING_BACKUP_VARIABLE} does not cover every managed ruleset; refusing to overwrite the recovery state.`
81
+ )
60
82
  }
61
83
 
62
84
  const changes = candidates.map(buildPausedRuleset)
@@ -65,7 +87,13 @@ export function manageCiBilling(root, action) {
65
87
  setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'true')
66
88
  const cancelledRuns = cancelActiveRuns(repository)
67
89
  for (const change of changes) updateRuleset(repository, change.ruleset)
68
- const result = { repository, paused: true, changed: true, cancelledRuns, rulesets: changes.map((change) => change.ruleset.name) }
90
+ const result = {
91
+ repository,
92
+ paused: true,
93
+ changed: true,
94
+ cancelledRuns,
95
+ rulesets: changes.map((change) => change.ruleset.name),
96
+ }
69
97
  console.log(JSON.stringify(result, null, 2))
70
98
  return result
71
99
  }
@@ -76,14 +104,18 @@ export function manageCiBilling(root, action) {
76
104
  console.log(JSON.stringify(result, null, 2))
77
105
  return result
78
106
  }
79
- throw new Error(`${CI_BILLING_PAUSED_VARIABLE} is true but ${CI_BILLING_BACKUP_VARIABLE} is missing; refusing an unsafe resume.`)
107
+ throw new Error(
108
+ `${CI_BILLING_PAUSED_VARIABLE} is true but ${CI_BILLING_BACKUP_VARIABLE} is missing; refusing an unsafe resume.`
109
+ )
80
110
  }
81
111
 
82
112
  let changed = paused
83
113
  for (const saved of backup.rulesets) {
84
114
  const current = apiJson(repository, `repos/${repository}/rulesets/${saved.rulesetId}`)
85
115
  if (!current || current.name !== saved.rulesetName) {
86
- throw new Error(`The backed-up ruleset ${saved.rulesetName} (${saved.rulesetId}) is unavailable; refusing an unsafe resume.`)
116
+ throw new Error(
117
+ `The backed-up ruleset ${saved.rulesetName} (${saved.rulesetId}) is unavailable; refusing an unsafe resume.`
118
+ )
87
119
  }
88
120
  const restored = buildResumedRuleset(current, saved)
89
121
  if (restored.changed) updateRuleset(repository, restored.ruleset)
@@ -91,27 +123,44 @@ export function manageCiBilling(root, action) {
91
123
  }
92
124
  setVariable(repository, CI_BILLING_PAUSED_VARIABLE, 'false')
93
125
  deleteVariable(repository, CI_BILLING_BACKUP_VARIABLE)
94
- const result = { repository, paused: false, changed, rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName) }
126
+ const result = {
127
+ repository,
128
+ paused: false,
129
+ changed,
130
+ rulesets: backup.rulesets.map((/** @type {any} */ ruleset) => ruleset.rulesetName),
131
+ }
95
132
  console.log(JSON.stringify(result, null, 2))
96
133
  return result
97
134
  }
98
135
 
99
136
  /** @param {any} ruleset */
100
137
  export function buildPausedRuleset(ruleset) {
101
- const requiredRuleIndexes = (ruleset.rules ?? []).flatMap((/** @type {any} */ rule, /** @type {number} */ index) =>
102
- rule?.type === 'required_status_checks' && (rule.parameters?.required_status_checks ?? []).some(isManagedCheck) ? [index] : [],
138
+ const requiredRuleIndexes = (ruleset.rules ?? []).flatMap(
139
+ (/** @type {any} */ rule, /** @type {number} */ index) =>
140
+ rule?.type === 'required_status_checks' &&
141
+ (rule.parameters?.required_status_checks ?? []).some(isManagedCheck)
142
+ ? [index]
143
+ : []
103
144
  )
104
- if (requiredRuleIndexes.length > 1) throw new Error(`${CI_BILLING_REQUIRED_CHECK} appears in multiple rules; refusing an ambiguous pause.`)
145
+ if (requiredRuleIndexes.length > 1)
146
+ throw new Error(
147
+ `${CI_BILLING_REQUIRED_CHECK} appears in multiple rules; refusing an ambiguous pause.`
148
+ )
105
149
  const requiredRuleIndex = requiredRuleIndexes[0]
106
150
  const requiredRule = ruleset.rules?.[requiredRuleIndex]
107
151
  const matches = (requiredRule?.parameters?.required_status_checks ?? []).filter(isManagedCheck)
108
- if (!matches.length) throw new Error(`${CI_BILLING_REQUIRED_CHECK} is not present in the selected ruleset.`)
109
- const remaining = (requiredRule.parameters?.required_status_checks ?? []).filter((/** @type {any} */ check) => !isManagedCheck(check))
110
- const rules = (ruleset.rules ?? []).flatMap((/** @type {any} */ rule, /** @type {number} */ index) => {
111
- if (index !== requiredRuleIndex) return [rule]
112
- if (!remaining.length) return []
113
- return [{ ...rule, parameters: { ...rule.parameters, required_status_checks: remaining } }]
114
- })
152
+ if (!matches.length)
153
+ throw new Error(`${CI_BILLING_REQUIRED_CHECK} is not present in the selected ruleset.`)
154
+ const remaining = (requiredRule.parameters?.required_status_checks ?? []).filter(
155
+ (/** @type {any} */ check) => !isManagedCheck(check)
156
+ )
157
+ const rules = (ruleset.rules ?? []).flatMap(
158
+ (/** @type {any} */ rule, /** @type {number} */ index) => {
159
+ if (index !== requiredRuleIndex) return [rule]
160
+ if (!remaining.length) return []
161
+ return [{ ...rule, parameters: { ...rule.parameters, required_status_checks: remaining } }]
162
+ }
163
+ )
115
164
  return {
116
165
  ruleset: { ...ruleset, rules },
117
166
  backup: {
@@ -119,7 +168,9 @@ export function buildPausedRuleset(ruleset) {
119
168
  rulesetName: ruleset.name,
120
169
  checks: matches,
121
170
  parameters: {
122
- strict_required_status_checks_policy: Boolean(requiredRule?.parameters?.strict_required_status_checks_policy),
171
+ strict_required_status_checks_policy: Boolean(
172
+ requiredRule?.parameters?.strict_required_status_checks_policy
173
+ ),
123
174
  do_not_enforce_on_create: Boolean(requiredRule?.parameters?.do_not_enforce_on_create),
124
175
  },
125
176
  },
@@ -151,34 +202,46 @@ export function buildResumedRuleset(ruleset, backup) {
151
202
 
152
203
  /** @param {any} backup */
153
204
  function validateBackup(backup) {
154
- return backup?.version === 1 &&
205
+ return (
206
+ backup?.version === 1 &&
155
207
  Array.isArray(backup.rulesets) &&
156
208
  backup.rulesets.length > 0 &&
157
- backup.rulesets.every((/** @type {any} */ ruleset) =>
158
- Number.isInteger(ruleset?.rulesetId) &&
159
- typeof ruleset.rulesetName === 'string' &&
160
- Array.isArray(ruleset.checks) &&
161
- ruleset.checks.length > 0 &&
162
- ruleset.checks.every(isManagedCheck) &&
163
- ruleset.parameters && typeof ruleset.parameters === 'object',
209
+ backup.rulesets.every(
210
+ (/** @type {any} */ ruleset) =>
211
+ Number.isInteger(ruleset?.rulesetId) &&
212
+ typeof ruleset.rulesetName === 'string' &&
213
+ Array.isArray(ruleset.checks) &&
214
+ ruleset.checks.length > 0 &&
215
+ ruleset.checks.every(isManagedCheck) &&
216
+ ruleset.parameters &&
217
+ typeof ruleset.parameters === 'object'
164
218
  )
219
+ )
165
220
  }
166
221
 
167
222
  /** @param {string} value */
168
223
  function parseBackup(value) {
169
224
  let backup
170
- try { backup = JSON.parse(value) }
171
- catch { throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is not valid JSON; refusing to change CI state.`) }
172
- if (!validateBackup(backup)) throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is invalid; refusing to change CI state.`)
225
+ try {
226
+ backup = JSON.parse(value)
227
+ } catch {
228
+ throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is not valid JSON; refusing to change CI state.`)
229
+ }
230
+ if (!validateBackup(backup))
231
+ throw new Error(`${CI_BILLING_BACKUP_VARIABLE} is invalid; refusing to change CI state.`)
173
232
  return backup
174
233
  }
175
234
 
176
235
  /** @param {any} check */
177
- function isManagedCheck(check) { return check?.context === CI_BILLING_REQUIRED_CHECK }
236
+ function isManagedCheck(check) {
237
+ return check?.context === CI_BILLING_REQUIRED_CHECK
238
+ }
178
239
 
179
240
  /** @param {any} ruleset @returns {any[]} */
180
241
  function requiredChecks(ruleset) {
181
- return (ruleset?.rules ?? []).flatMap((/** @type {any} */ rule) => rule?.type === 'required_status_checks' ? rule.parameters?.required_status_checks ?? [] : [])
242
+ return (ruleset?.rules ?? []).flatMap((/** @type {any} */ rule) =>
243
+ rule?.type === 'required_status_checks' ? (rule.parameters?.required_status_checks ?? []) : []
244
+ )
182
245
  }
183
246
 
184
247
  /** @param {string} repository @returns {any[]} */
@@ -187,7 +250,10 @@ function activeBranchRulesets(repository) {
187
250
  if (!Array.isArray(summaries)) throw new Error('Unable to read repository rulesets.')
188
251
  return summaries
189
252
  .map((ruleset) => apiJson(repository, `repos/${repository}/rulesets/${ruleset.id}`))
190
- .filter((/** @type {any} */ ruleset) => ruleset && ruleset.enforcement === 'active' && ruleset.target === 'branch')
253
+ .filter(
254
+ (/** @type {any} */ ruleset) =>
255
+ ruleset && ruleset.enforcement === 'active' && ruleset.target === 'branch'
256
+ )
191
257
  }
192
258
 
193
259
  /** @param {string} repository @param {any} ruleset */
@@ -200,26 +266,42 @@ function updateRuleset(repository, ruleset) {
200
266
  conditions: ruleset.conditions,
201
267
  rules: ruleset.rules,
202
268
  }
203
- const result = spawnSync('gh', ['api', '--method', 'PUT', `repos/${repository}/rulesets/${ruleset.id}`, '--input', '-'], {
204
- encoding: 'utf8',
205
- input: JSON.stringify(payload),
206
- })
207
- if (result.status !== 0) throw new Error(`Unable to update ruleset ${ruleset.name}: ${result.stderr.trim() || 'GitHub API request failed'}`)
269
+ const result = spawnSync(
270
+ 'gh',
271
+ ['api', '--method', 'PUT', `repos/${repository}/rulesets/${ruleset.id}`, '--input', '-'],
272
+ {
273
+ encoding: 'utf8',
274
+ input: JSON.stringify(payload),
275
+ }
276
+ )
277
+ if (result.status !== 0)
278
+ throw new Error(
279
+ `Unable to update ruleset ${ruleset.name}: ${result.stderr.trim() || 'GitHub API request failed'}`
280
+ )
208
281
  }
209
282
 
210
283
  /** @param {string} repository @returns {number[]} */
211
284
  function cancelActiveRuns(repository) {
212
285
  const ids = new Set()
213
286
  for (const status of ['queued', 'in_progress', 'requested', 'waiting', 'pending']) {
214
- const response = apiJson(repository, `repos/${repository}/actions/runs?status=${status}&per_page=100`)
287
+ const response = apiJson(
288
+ repository,
289
+ `repos/${repository}/actions/runs?status=${status}&per_page=100`
290
+ )
215
291
  for (const run of response?.workflow_runs ?? []) {
216
292
  if (Number.isInteger(run?.id)) ids.add(run.id)
217
293
  }
218
294
  }
219
295
  for (const id of ids) {
220
- const result = spawnSync('gh', ['api', '--method', 'POST', `repos/${repository}/actions/runs/${id}/cancel`], { encoding: 'utf8' })
296
+ const result = spawnSync(
297
+ 'gh',
298
+ ['api', '--method', 'POST', `repos/${repository}/actions/runs/${id}/cancel`],
299
+ { encoding: 'utf8' }
300
+ )
221
301
  if (result.status !== 0 && !/409|cannot be cancelled/i.test(result.stderr)) {
222
- throw new Error(`Unable to cancel workflow run ${id}: ${result.stderr.trim() || 'GitHub API request failed'}`)
302
+ throw new Error(
303
+ `Unable to cancel workflow run ${id}: ${result.stderr.trim() || 'GitHub API request failed'}`
304
+ )
223
305
  }
224
306
  }
225
307
  return [...ids]
@@ -227,42 +309,69 @@ function cancelActiveRuns(repository) {
227
309
 
228
310
  /** @param {string} repository @param {string} name @returns {string|null} */
229
311
  function readVariable(repository, name) {
230
- const result = spawnSync('gh', ['api', `repos/${repository}/actions/variables/${name}`], { encoding: 'utf8' })
312
+ const result = spawnSync('gh', ['api', `repos/${repository}/actions/variables/${name}`], {
313
+ encoding: 'utf8',
314
+ })
231
315
  if (result.status !== 0) {
232
316
  if (/404|not found/i.test(result.stderr)) return null
233
- throw new Error(`Unable to read repository variable ${name}: ${result.stderr.trim() || 'GitHub API request failed'}`)
317
+ throw new Error(
318
+ `Unable to read repository variable ${name}: ${result.stderr.trim() || 'GitHub API request failed'}`
319
+ )
320
+ }
321
+ try {
322
+ return JSON.parse(result.stdout)?.value ?? null
323
+ } catch {
324
+ throw new Error(`GitHub returned invalid JSON for repository variable ${name}.`)
234
325
  }
235
- try { return JSON.parse(result.stdout)?.value ?? null }
236
- catch { throw new Error(`GitHub returned invalid JSON for repository variable ${name}.`) }
237
326
  }
238
327
 
239
328
  /** @param {string} repository @param {string} name @param {string} value */
240
329
  function setVariable(repository, name, value) {
241
- const result = spawnSync('gh', ['variable', 'set', name, '--repo', repository, '--body', value], { encoding: 'utf8' })
242
- if (result.status !== 0) throw new Error(`Unable to set repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`)
330
+ const result = spawnSync('gh', ['variable', 'set', name, '--repo', repository, '--body', value], {
331
+ encoding: 'utf8',
332
+ })
333
+ if (result.status !== 0)
334
+ throw new Error(
335
+ `Unable to set repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`
336
+ )
243
337
  }
244
338
 
245
339
  /** @param {string} repository @param {string} name */
246
340
  function deleteVariable(repository, name) {
247
- const result = spawnSync('gh', ['variable', 'delete', name, '--repo', repository], { encoding: 'utf8' })
248
- if (result.status !== 0) throw new Error(`Unable to delete repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`)
341
+ const result = spawnSync('gh', ['variable', 'delete', name, '--repo', repository], {
342
+ encoding: 'utf8',
343
+ })
344
+ if (result.status !== 0)
345
+ throw new Error(
346
+ `Unable to delete repository variable ${name}: ${result.stderr.trim() || 'GitHub CLI request failed'}`
347
+ )
249
348
  }
250
349
 
251
350
  /** @param {string} repository @param {string} endpoint @returns {any} */
252
351
  function apiJson(repository, endpoint) {
253
352
  const result = spawnSync('gh', ['api', endpoint], { encoding: 'utf8' })
254
- if (result.status !== 0) throw new Error(`Unable to read ${endpoint} for ${repository}: ${result.stderr.trim() || 'GitHub API request failed'}`)
255
- try { return JSON.parse(result.stdout) }
256
- catch { throw new Error(`GitHub returned invalid JSON for ${endpoint}.`) }
353
+ if (result.status !== 0)
354
+ throw new Error(
355
+ `Unable to read ${endpoint} for ${repository}: ${result.stderr.trim() || 'GitHub API request failed'}`
356
+ )
357
+ try {
358
+ return JSON.parse(result.stdout)
359
+ } catch {
360
+ throw new Error(`GitHub returned invalid JSON for ${endpoint}.`)
361
+ }
257
362
  }
258
363
 
259
364
  /** @param {string} root @returns {string} */
260
365
  function remoteRepository(root) {
261
366
  const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' })
262
367
  const value = result.status === 0 ? result.stdout.trim() : ''
263
- return value.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
368
+ return value
369
+ .replace(/^git@github\.com:/, '')
370
+ .replace(/^https?:\/\/github\.com\//, '')
371
+ .replace(/\.git$/, '')
264
372
  }
265
373
 
266
374
  function requireGh() {
267
- if (spawnSync('gh', ['--version'], { stdio: 'ignore' }).status !== 0) throw new Error('CI billing controls require the `gh` CLI.')
375
+ if (spawnSync('gh', ['--version'], { stdio: 'ignore' }).status !== 0)
376
+ throw new Error('CI billing controls require the `gh` CLI.')
268
377
  }
@@ -16,56 +16,97 @@ export function doctor(root, options = {}) {
16
16
  const recommendations = recommendRunners(target)
17
17
  let errors = 0
18
18
  /** @param {string} message */
19
- const error = (message) => { console.error(`ERROR: ${message}`); errors += 1 }
19
+ const error = (message) => {
20
+ console.error(`ERROR: ${message}`)
21
+ errors += 1
22
+ }
20
23
  /** @param {string} message */
21
24
  const warn = (message) => console.warn(`WARN: ${message}`)
22
25
 
23
26
  const toolchain = config.toolchain ?? 'auto'
24
27
  if (!['auto', 'native', 'mise'].includes(toolchain)) error(`unsupported toolchain: ${toolchain}`)
25
- if (toolchain === 'mise' && !existsSync(join(target, '.mise.toml'))) error('.mise.toml is required when toolchain: mise')
28
+ if (toolchain === 'mise' && !existsSync(join(target, '.mise.toml')))
29
+ error('.mise.toml is required when toolchain: mise')
26
30
  if (toolchain === 'auto') {
27
- console.log(existsSync(join(target, '.mise.toml'))
28
- ? 'Toolchain: mise (detected from existing .mise.toml).'
29
- : 'Toolchain: native (mise not configured).')
31
+ console.log(
32
+ existsSync(join(target, '.mise.toml'))
33
+ ? 'Toolchain: mise (detected from existing .mise.toml).'
34
+ : 'Toolchain: native (mise not configured).'
35
+ )
30
36
  }
31
37
  const hooks = git(target, ['config', '--get', 'core.hooksPath'])
32
38
  if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
33
- if (['rust', 'python', 'solidity'].some((language) => profile.languages.split(',').includes(language)) && (config.unit_runner ?? recommendations.unit_runner) === 'ubuntu-slim') {
34
- warn('unit_runner is ubuntu-slim for a native-toolchain repository; ubuntu-latest is recommended.')
39
+ if (
40
+ ['rust', 'python', 'solidity'].some((language) =>
41
+ profile.languages.split(',').includes(language)
42
+ ) &&
43
+ (config.unit_runner ?? recommendations.unit_runner) === 'ubuntu-slim'
44
+ ) {
45
+ warn(
46
+ 'unit_runner is ubuntu-slim for a native-toolchain repository; ubuntu-latest is recommended.'
47
+ )
35
48
  }
36
49
 
37
50
  const packageFile = join(target, 'package.json')
38
51
  if (existsSync(packageFile)) {
39
52
  let packageJson
40
- try { packageJson = JSON.parse(readFileSync(packageFile, 'utf8')) }
41
- catch { error('package.json is not valid JSON'); packageJson = {} }
42
- const lockfiles = ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].filter((file) => existsSync(join(target, file)))
53
+ try {
54
+ packageJson = JSON.parse(readFileSync(packageFile, 'utf8'))
55
+ } catch {
56
+ error('package.json is not valid JSON')
57
+ packageJson = {}
58
+ }
59
+ const lockfiles = [
60
+ 'bun.lock',
61
+ 'bun.lockb',
62
+ 'pnpm-lock.yaml',
63
+ 'yarn.lock',
64
+ 'package-lock.json',
65
+ ].filter((file) => existsSync(join(target, file)))
43
66
  const groups = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
44
- if (!lockfiles.length && groups.some((key) => Object.keys(packageJson[key] ?? {}).length)) error('package.json exists but no supported lockfile was found')
67
+ if (!lockfiles.length && groups.some((key) => Object.keys(packageJson[key] ?? {}).length))
68
+ error('package.json exists but no supported lockfile was found')
45
69
  if (lockfiles.length > 1) error('multiple JavaScript lockfiles found; keep one package manager')
46
70
  if (packageJson.packageManager && lockfiles.length) {
47
71
  const declared = String(packageJson.packageManager).split('@')[0]
48
- const actual = lockfiles[0]?.startsWith('bun') ? 'bun' : lockfiles[0]?.split('-')[0].replace('.yaml', '')
49
- if (actual && declared !== actual) error(`packageManager (${declared}) does not match ${actual} lockfile`)
72
+ const actual = lockfiles[0]?.startsWith('bun')
73
+ ? 'bun'
74
+ : lockfiles[0]?.split('-')[0].replace('.yaml', '')
75
+ if (actual && declared !== actual)
76
+ error(`packageManager (${declared}) does not match ${actual} lockfile`)
50
77
  }
51
78
  }
52
79
 
53
- if (profile.languages.split(',').includes('rust') && !commandExists('cargo')) error('Cargo is required for this repository')
80
+ if (profile.languages.split(',').includes('rust') && !commandExists('cargo'))
81
+ error('Cargo is required for this repository')
54
82
  if (profile.languages.split(',').includes('rust')) {
55
- const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], { cwd: target, stdio: 'ignore' })
83
+ const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1'], {
84
+ cwd: target,
85
+ stdio: 'ignore',
86
+ })
56
87
  if (metadata.status !== 0) error('cargo metadata failed')
57
88
  }
58
- if (profile.languages.split(',').includes('python') && !commandExists('python') && !existsSync(join(target, '.venv/bin/python'))) error('Python is required for this repository')
89
+ if (
90
+ profile.languages.split(',').includes('python') &&
91
+ !commandExists('python') &&
92
+ !existsSync(join(target, '.venv/bin/python'))
93
+ )
94
+ error('Python is required for this repository')
59
95
 
60
96
  const releaseConfigPath = join(target, 'release-please-config.json')
61
97
  if (existsSync(releaseConfigPath)) {
62
98
  /** @type {Record<string, any>} */
63
99
  let releaseConfig = {}
64
- try { releaseConfig = JSON.parse(readFileSync(releaseConfigPath, 'utf8')) }
65
- catch { error('release-please-config.json is not valid JSON') }
100
+ try {
101
+ releaseConfig = JSON.parse(readFileSync(releaseConfigPath, 'utf8'))
102
+ } catch {
103
+ error('release-please-config.json is not valid JSON')
104
+ }
66
105
  const manifestMode = Boolean(releaseConfig.packages || releaseConfig['release-type'])
67
106
  if (manifestMode && !existsSync(join(target, '.release-please-manifest.json'))) {
68
- error('release-please-config.json requires .release-please-manifest.json; run code-foundry sync to bootstrap it')
107
+ error(
108
+ 'release-please-config.json requires .release-please-manifest.json; run code-foundry sync to bootstrap it'
109
+ )
69
110
  }
70
111
  }
71
112
 
@@ -76,17 +117,24 @@ export function doctor(root, options = {}) {
76
117
  }
77
118
  const mergeStrategy = config.merge_strategy ?? 'rebase'
78
119
  if (workflow === 'staging-release' && mergeStrategy !== 'rebase') {
79
- error(`merge_strategy must be "rebase" for the staging-release promotion topology; got "${mergeStrategy}".`)
120
+ error(
121
+ `merge_strategy must be "rebase" for the staging-release promotion topology; got "${mergeStrategy}".`
122
+ )
80
123
  }
81
124
  const releaseMergeStrategy = config.release_merge_strategy ?? ''
82
125
  if (includesValue(features, 'release') && releaseMergeStrategy !== 'rebase') {
83
- error(`release_merge_strategy must be "rebase" for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`)
126
+ error(
127
+ `release_merge_strategy must be "rebase" for automated release merges; got "${releaseMergeStrategy || '(unset; release automation never defaults to merge)'}".`
128
+ )
84
129
  }
85
130
  for (const name of ['validation', 'draft-pr', 'release-pr', 'release']) {
86
131
  if (name === 'release-pr' && workflow !== 'staging-release') continue
87
- if (includesValue(features, name) && !existsSync(join(target, `.github/workflows/${name}.yml`))) error(`missing enabled workflow: ${name}.yml`)
132
+ if (includesValue(features, name) && !existsSync(join(target, `.github/workflows/${name}.yml`)))
133
+ error(`missing enabled workflow: ${name}.yml`)
88
134
  }
89
- const validationEnabled = includesValue(features, 'validation') || ['ci', 'test', 'security', 'codeql'].some((legacy) => includesValue(features, legacy))
135
+ const validationEnabled =
136
+ includesValue(features, 'validation') ||
137
+ ['ci', 'test', 'security', 'codeql'].some((legacy) => includesValue(features, legacy))
90
138
  const validationCaller = ['validation.yml', 'validation_self-ci.yml']
91
139
  .map((file) => join(target, `.github/workflows/${file}`))
92
140
  .find((file) => existsSync(file) && /pull_request:/.test(readFileSync(file, 'utf8')))
@@ -95,15 +143,23 @@ export function doctor(root, options = {}) {
95
143
  } else if (validationCaller) {
96
144
  const caller = readFileSync(validationCaller, 'utf8')
97
145
  if (!/^ validation:\n name: Validation/m.test(caller)) {
98
- error('validation caller is missing the Validation job; the Validation / Gate aggregate check cannot form.')
146
+ error(
147
+ 'validation caller is missing the Validation job; the Validation / Gate aggregate check cannot form.'
148
+ )
99
149
  }
100
- if (!/uses:\s+(?:\.\/\.github\/workflows\/validation\.yml|\S+\/\.github\/workflows\/validation\.yml@)/.test(caller)) {
150
+ if (
151
+ !/uses:\s+(?:\.\/\.github\/workflows\/validation\.yml|\S+\/\.github\/workflows\/validation\.yml@)/.test(
152
+ caller
153
+ )
154
+ ) {
101
155
  error('validation caller does not reference the validation orchestrator.')
102
156
  }
103
157
  const runtimeRef = caller.match(/^\s+runtime-ref:\s+(.+?)\s*$/m)?.[1]
104
158
  const checkoutRef = caller.match(/^\s+ref:\s+(.+?)\s*$/m)?.[1]
105
159
  if (!runtimeRef || !checkoutRef) {
106
- error('validation caller is missing runtime ref wiring (mode checkout or orchestrator input).')
160
+ error(
161
+ 'validation caller is missing runtime ref wiring (mode checkout or orchestrator input).'
162
+ )
107
163
  } else if (runtimeRef !== checkoutRef) {
108
164
  error(`validation caller pins mismatched runtime refs (${runtimeRef} vs ${checkoutRef}).`)
109
165
  } else if (runtimeRef !== '${{ github.sha }}' && !/^v\d+\.\d+\.\d+$/.test(runtimeRef)) {
@@ -113,11 +169,18 @@ export function doctor(root, options = {}) {
113
169
  const runtimeRepository = config.runtime_repository ?? '0xPlayerOne/code-foundry'
114
170
  for (const stem of ['ci', 'test', 'security', 'codeql']) {
115
171
  const legacy = join(target, `.github/workflows/${stem}.yml`)
116
- if (existsSync(legacy) && isGeneratedEventCaller(readFileSync(legacy, 'utf8'), stem, runtimeRepository)) {
117
- warn(`stale generated legacy caller ${stem}.yml still triggers canonical suites; run code-foundry sync to migrate to validation.yml.`)
172
+ if (
173
+ existsSync(legacy) &&
174
+ isGeneratedEventCaller(readFileSync(legacy, 'utf8'), stem, runtimeRepository)
175
+ ) {
176
+ warn(
177
+ `stale generated legacy caller ${stem}.yml still triggers canonical suites; run code-foundry sync to migrate to validation.yml.`
178
+ )
118
179
  }
119
180
  }
120
- console.log('Remote CI, Test, Security, CodeQL, and release runtimes are loaded by the tiered validation orchestrator.')
181
+ console.log(
182
+ 'Remote CI, Test, Security, CodeQL, and release runtimes are loaded by the tiered validation orchestrator.'
183
+ )
121
184
  if (options.github) {
122
185
  const github = doctorGithub(target)
123
186
  /** @type {{ codeFoundryTokenPresent?: boolean, stagingDeployKeyPresent?: boolean }} */
@@ -127,9 +190,13 @@ export function doctor(root, options = {}) {
127
190
  console.log(`GitHub doctor inspected ${github.details.repository}.`)
128
191
  const codeFoundryToken = secrets.codeFoundryTokenPresent ? 'present' : 'absent'
129
192
  const stagingDeployKey = secrets.stagingDeployKeyPresent ? 'present' : 'absent'
130
- console.log(`GitHub secrets: CODE_FOUNDRY_TOKEN=${codeFoundryToken}, STAGING_DEPLOY_KEY=${stagingDeployKey}`)
193
+ console.log(
194
+ `GitHub secrets: CODE_FOUNDRY_TOKEN=${codeFoundryToken}, STAGING_DEPLOY_KEY=${stagingDeployKey}`
195
+ )
131
196
  if (!secrets.codeFoundryTokenPresent) {
132
- warn('GitHub token routing fallback: PR creation will remain draft and requires manual ready-for-review to trigger validation.')
197
+ warn(
198
+ 'GitHub token routing fallback: PR creation will remain draft and requires manual ready-for-review to trigger validation.'
199
+ )
133
200
  }
134
201
  }
135
202
  if (errors) throw new Error(`Repository doctor found ${errors} error(s).`)